├── LICENSE ├── README.md ├── lua └── cmp_git │ ├── config.lua │ ├── format.lua │ ├── init.lua │ ├── log.lua │ ├── sort.lua │ ├── source.lua │ ├── sources │ ├── git.lua │ ├── github.lua │ └── gitlab.lua │ └── utils.lua ├── selene.toml ├── stylua.toml └── vim.toml /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Peter Tri Ho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cmp-git 2 | 3 | Git source for [hrsh7th/nvim-cmp](https://github.com/hrsh7th/nvim-cmp) 4 | 5 | ## Features 6 | 7 | | Git | Trigger | 8 | | ------- | ------- | 9 | | Commits | : | 10 | 11 | | GitHub | Trigger | 12 | | ---------------------- | ------- | 13 | | Issues | # | 14 | | Mentions (`curl` only) | @ | 15 | | Pull Requests | # | 16 | 17 | | GitLab | Trigger | 18 | | -------------- | ------- | 19 | | Issues | # | 20 | | Mentions | @ | 21 | | Merge Requests | ! | 22 | 23 | ## Requirements 24 | 25 | - Neovim >= 0.5.1 26 | - git 27 | - curl 28 | - [GitHub CLI](https://cli.github.com/) (optional, will use curl instead if not avaliable) 29 | - [GitLab CLI](https://gitlab.com/gitlab-org/cli) (optional, will use curl instead if not avaliable) 30 | 31 | ### GitHub Private Repositories 32 | 33 | - `curl`: Generate [token](https://github.com/settings/tokens) 34 | with `repo` scope. Set `GITHUB_API_TOKEN` environment variable. 35 | - `GitHub CLI`: Run [gh auth login](https://cli.github.com/manual/gh_auth_login) 36 | 37 | ### GitLab Private Repositories 38 | 39 | - `curl` Generate [token](https://gitlab.com/-/profile/personal_access_tokens) 40 | with `api` scope. Set `GITLAB_TOKEN` environment variable. 41 | - `GitLab CLI`: Run [glab auth login](https://glab.readthedocs.io/en/latest/auth/login.html) 42 | 43 | ## Installation 44 | 45 | [vim-plug](https://github.com/junegunn/vim-plug) 46 | 47 | ```vim 48 | Plug 'nvim-lua/plenary.nvim' 49 | Plug 'petertriho/cmp-git' 50 | ``` 51 | 52 | [packer.nvim](https://github.com/wbthomason/packer.nvim) 53 | 54 | ```lua 55 | use({"petertriho/cmp-git", requires = "nvim-lua/plenary.nvim"}) 56 | ``` 57 | 58 | [lazy.nvim](https://github.com/folke/lazy.nvim) 59 | 60 | ```lua 61 | return { 62 | "petertriho/cmp-git", 63 | dependencies = { 'hrsh7th/nvim-cmp' }, 64 | opts = { 65 | -- options go here 66 | }, 67 | init = function() 68 | table.insert(require("cmp").get_config().sources, { name = "git" }) 69 | end 70 | } 71 | ``` 72 | 73 | ## Setup 74 | 75 | ```lua 76 | require("cmp").setup({ 77 | sources = { 78 | { name = "git" }, 79 | -- more sources 80 | } 81 | }) 82 | 83 | require("cmp_git").setup() 84 | ``` 85 | 86 | ## Config 87 | 88 | ```lua 89 | local format = require("cmp_git.format") 90 | local sort = require("cmp_git.sort") 91 | 92 | require("cmp_git").setup({ 93 | -- defaults 94 | filetypes = { "gitcommit", "octo", "NeogitCommitMessage" }, 95 | remotes = { "upstream", "origin" }, -- in order of most to least prioritized 96 | enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf 97 | git = { 98 | commits = { 99 | limit = 100, 100 | sort_by = sort.git.commits, 101 | format = format.git.commits, 102 | sha_length = 7, 103 | }, 104 | }, 105 | github = { 106 | hosts = {}, -- list of private instances of github 107 | issues = { 108 | fields = { "title", "number", "body", "updatedAt", "state" }, 109 | filter = "all", -- assigned, created, mentioned, subscribed, all, repos 110 | limit = 100, 111 | state = "open", -- open, closed, all 112 | sort_by = sort.github.issues, 113 | format = format.github.issues, 114 | }, 115 | mentions = { 116 | limit = 100, 117 | sort_by = sort.github.mentions, 118 | format = format.github.mentions, 119 | }, 120 | pull_requests = { 121 | fields = { "title", "number", "body", "updatedAt", "state" }, 122 | limit = 100, 123 | state = "open", -- open, closed, merged, all 124 | sort_by = sort.github.pull_requests, 125 | format = format.github.pull_requests, 126 | }, 127 | }, 128 | gitlab = { 129 | hosts = {}, -- list of private instances of gitlab 130 | issues = { 131 | limit = 100, 132 | state = "opened", -- opened, closed, all 133 | sort_by = sort.gitlab.issues, 134 | format = format.gitlab.issues, 135 | }, 136 | mentions = { 137 | limit = 100, 138 | sort_by = sort.gitlab.mentions, 139 | format = format.gitlab.mentions, 140 | }, 141 | merge_requests = { 142 | limit = 100, 143 | state = "opened", -- opened, closed, locked, merged 144 | sort_by = sort.gitlab.merge_requests, 145 | format = format.gitlab.merge_requests, 146 | }, 147 | }, 148 | trigger_actions = { 149 | { 150 | debug_name = "git_commits", 151 | trigger_character = ":", 152 | action = function(sources, trigger_char, callback, params, git_info) 153 | return sources.git:get_commits(callback, params, trigger_char) 154 | end, 155 | }, 156 | { 157 | debug_name = "gitlab_issues", 158 | trigger_character = "#", 159 | action = function(sources, trigger_char, callback, params, git_info) 160 | return sources.gitlab:get_issues(callback, git_info, trigger_char) 161 | end, 162 | }, 163 | { 164 | debug_name = "gitlab_mentions", 165 | trigger_character = "@", 166 | action = function(sources, trigger_char, callback, params, git_info) 167 | return sources.gitlab:get_mentions(callback, git_info, trigger_char) 168 | end, 169 | }, 170 | { 171 | debug_name = "gitlab_mrs", 172 | trigger_character = "!", 173 | action = function(sources, trigger_char, callback, params, git_info) 174 | return sources.gitlab:get_merge_requests(callback, git_info, trigger_char) 175 | end, 176 | }, 177 | { 178 | debug_name = "github_issues_and_pr", 179 | trigger_character = "#", 180 | action = function(sources, trigger_char, callback, params, git_info) 181 | return sources.github:get_issues_and_prs(callback, git_info, trigger_char) 182 | end, 183 | }, 184 | { 185 | debug_name = "github_mentions", 186 | trigger_character = "@", 187 | action = function(sources, trigger_char, callback, params, git_info) 188 | return sources.github:get_mentions(callback, git_info, trigger_char) 189 | end, 190 | }, 191 | }, 192 | } 193 | ) 194 | ``` 195 | 196 | --- 197 | 198 | **NOTE** 199 | 200 | If you want specific behaviour for a trigger or new behaviour for a trigger, you need to add 201 | an entry in the `trigger_actions` table of the config. The two necessary fields are the `trigger_character` 202 | and the `action`. 203 | 204 | Currently, `trigger_character` has to be a single character. Multiple actions can be used for the same character. 205 | All actions are triggered until one returns true. The parameters to the `actions` function are the 206 | different sources (currently `git`, `gitlab` and `github`), the completion callback, the trigger character, 207 | the parameters passed to `complete` from `nvim-cmp`, and the current git info. 208 | 209 | All source functions take an optional config table as last argument, with which the configuration set 210 | in `setup` can be overwritten for a specific call. 211 | 212 | **NOTE on sorting** 213 | 214 | The default sorting order is last updated (for PRs, MRs and issues) and latest (for commits). 215 | To make `nvim-cmp` sort in this order, move `cmp.config.compare.sort_text` closer to the top of (lower index) in `sorting.comparators`. E.g. 216 | 217 | ```lua 218 | require("cmp").setup({ 219 | -- As above 220 | sorting = { 221 | comparators = { 222 | cmp.config.compare.offset, 223 | cmp.config.compare.exact, 224 | cmp.config.compare.sort_text, 225 | cmp.config.compare.score, 226 | cmp.config.compare.recently_used, 227 | cmp.config.compare.kind, 228 | cmp.config.compare.length, 229 | cmp.config.compare.order, 230 | }, 231 | }, 232 | }) 233 | ``` 234 | 235 | ### Working with hosted instances of GitHub or GitLab 236 | 237 | You can add hosted instances of Github Enterprise or GitLab to the corresponding `hosts` list as such: 238 | ```lua 239 | require("cmp_git").setup({ 240 | github = { 241 | hosts = { "github.mycompany.com", }, 242 | }, 243 | gitlab = { 244 | hosts = { "gitlab.mycompany.com", } 245 | } 246 | } 247 | ``` 248 | 249 | --- 250 | 251 | ## Acknowledgements 252 | 253 | Special thanks to [tjdevries](https://github.com/tjdevries) for their informative video and starting code. 254 | 255 | - [TakeTuesday E01: nvim-cmp](https://www.youtube.com/watch?v=_DnmphIwnjo) 256 | - [tjdevries/config_manager](https://github.com/tjdevries/config_manager) 257 | 258 | ## Alternatives 259 | 260 | - [neoclide/coc-git](https://github.com/neoclide/coc-git) 261 | 262 | ## License 263 | 264 | [MIT](https://choosealicense.com/licenses/mit/) 265 | -------------------------------------------------------------------------------- /lua/cmp_git/config.lua: -------------------------------------------------------------------------------- 1 | local format = require("cmp_git.format") 2 | local sort = require("cmp_git.sort") 3 | 4 | ---@class cmp_git.Config.TriggerAction 5 | ---@field debug_name string 6 | ---@field trigger_character string 7 | ---@field action fun(sources: cmp_git.Sources, trigger_char: string, callback: fun(list: cmp_git.CompletionList), params: cmp.SourceCompletionApiParams, git_info: cmp_git.GitInfo): boolean 8 | 9 | ---@class cmp_git.Config 10 | local M = { 11 | ---@type string[] 12 | filetypes = { "gitcommit", "octo", "NeogitCommitMessage" }, 13 | ---@type string[] 14 | remotes = { "upstream", "origin" }, -- in order of most to least prioritized 15 | ---@type boolean 16 | enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf 17 | ---@type table 18 | ssh_aliases = {}, 19 | ---@class cmp_git.Config.Git 20 | ---@field filter_fn? fun(trigger_char: string, item: cmp_git.Commit): string 21 | ---@field format? cmp_git.FormatConfig 22 | git = { 23 | ---@class cmp_git.Config.GitCommits 24 | commits = { 25 | limit = 100, 26 | sort_by = sort.git.commits, 27 | format = format.git.commits, 28 | sha_length = 7, 29 | }, 30 | }, 31 | ---@class cmp_git.Config.GitHub 32 | ---@field format? cmp_git.FormatConfig 33 | ---@field filter_fn? fun(trigger_char: string, item: cmp_git.GitHub.Issue | cmp_git.GitHub.Mention | cmp_git.GitHub.PullRequest): string 34 | github = { 35 | ---@type string[] 36 | hosts = {}, 37 | ---@class cmp_git.Config.GitHub.Issue 38 | issues = { 39 | ---@type string[] 40 | fields = { "title", "number", "body", "updatedAt", "state" }, 41 | ---Filter by preconfigured options ('all', 'assigned', 'created', 'mentioned') 42 | ---@type 'all' | 'assigned' | 'created' | 'mentioned' 43 | filter = "all", 44 | limit = 100, 45 | ---@type 'open' | 'closed' | 'all' 46 | state = "open", 47 | sort_by = sort.github.issues, 48 | format = format.github.issues, 49 | }, 50 | mentions = { 51 | -- Use math.huge to fetch until there are no more results 52 | limit = 100, 53 | sort_by = sort.github.mentions, 54 | format = format.github.mentions, 55 | }, 56 | ---@class cmp_git.Config.GitHub.PullRequest 57 | pull_requests = { 58 | ---@type string[] 59 | fields = { "title", "number", "body", "updatedAt", "state" }, 60 | limit = 100, 61 | state = "open", -- open, closed, merged, all 62 | sort_by = sort.github.pull_requests, 63 | format = format.github.pull_requests, 64 | }, 65 | }, 66 | ---@class cmp_git.Config.Gitlab 67 | ---@field filter_fn? fun(trigger_char: string, item: any): string 68 | ---@field format? cmp_git.FormatConfig 69 | gitlab = { 70 | hosts = {}, 71 | issues = { 72 | limit = 100, 73 | state = "opened", -- opened, closed, all 74 | sort_by = sort.gitlab.issues, 75 | format = format.gitlab.issues, 76 | }, 77 | mentions = { 78 | limit = 100, 79 | sort_by = sort.gitlab.mentions, 80 | format = format.gitlab.mentions, 81 | }, 82 | merge_requests = { 83 | limit = 100, 84 | state = "opened", -- opened, closed, locked, merged 85 | sort_by = sort.gitlab.merge_requests, 86 | format = format.gitlab.merge_requests, 87 | }, 88 | }, 89 | ---@type cmp_git.Config.TriggerAction[] 90 | trigger_actions = { 91 | { 92 | debug_name = "git_commits", 93 | trigger_character = ":", 94 | action = function(sources, trigger_char, callback, params, git_info) 95 | return sources.git:get_commits(callback, params, trigger_char) 96 | end, 97 | }, 98 | { 99 | debug_name = "gitlab_issues", 100 | trigger_character = "#", 101 | action = function(sources, trigger_char, callback, params, git_info) 102 | return sources.gitlab:get_issues(callback, git_info, trigger_char) 103 | end, 104 | }, 105 | { 106 | debug_name = "gitlab_mentions", 107 | trigger_character = "@", 108 | action = function(sources, trigger_char, callback, params, git_info) 109 | return sources.gitlab:get_mentions(callback, git_info, trigger_char) 110 | end, 111 | }, 112 | { 113 | debug_name = "gitlab_mrs", 114 | trigger_character = "!", 115 | action = function(sources, trigger_char, callback, params, git_info) 116 | return sources.gitlab:get_merge_requests(callback, git_info, trigger_char) 117 | end, 118 | }, 119 | { 120 | debug_name = "github_issues_and_pr", 121 | trigger_character = "#", 122 | action = function(sources, trigger_char, callback, params, git_info) 123 | return sources.github:get_issues_and_prs(callback, git_info, trigger_char) 124 | end, 125 | }, 126 | { 127 | debug_name = "github_mentions", 128 | trigger_character = "@", 129 | action = function(sources, trigger_char, callback, params, git_info) 130 | return sources.github:get_mentions(callback, git_info, trigger_char) 131 | end, 132 | }, 133 | }, 134 | } 135 | 136 | return M 137 | -------------------------------------------------------------------------------- /lua/cmp_git/format.lua: -------------------------------------------------------------------------------- 1 | local sort = require("cmp_git.sort") 2 | 3 | ---@class cmp_git.FormatConfig: { 4 | ---label: fun(trigger_char: string, item: TItem): string; 5 | ---filterText: fun(trigger_char: string, item: TItem): string; 6 | ---insertText: fun(trigger_char: string, item: TItem): string; 7 | ---documentation: fun(trigger_char: string, item: TItem): lsp.MarkupContent; 8 | ---} 9 | 10 | local M = { 11 | git = { 12 | ---@type cmp_git.FormatConfig 13 | commits = { 14 | label = function(trigger_char, commit) 15 | return string.format("%s: %s", commit.sha:sub(0, 7), commit.title) 16 | end, 17 | filterText = function(trigger_char, commit) 18 | -- If the trigger char is not part of the label, no items will show up 19 | return string.format("%s %s %s", trigger_char, commit.sha:sub(0, 7), commit.title) 20 | end, 21 | insertText = function(trigger_char, commit) 22 | return commit.sha 23 | end, 24 | documentation = function(trigger_char, commit) 25 | return { 26 | kind = "markdown", 27 | value = string.format( 28 | "# %s\n\n%s\n\nCommited by %s (%s) on %s", 29 | commit.title, 30 | commit.description, 31 | commit.author_name, 32 | commit.author_mail, 33 | os.date("%c", commit.commit_timestamp) 34 | ), 35 | } 36 | end, 37 | }, 38 | }, 39 | github = { 40 | ---@type cmp_git.FormatConfig 41 | issues = { 42 | label = function(trigger_char, issue) 43 | return string.format("#%s: %s", issue.number, issue.title) 44 | end, 45 | insertText = function(trigger_char, issue) 46 | return string.format("#%s", issue.number) 47 | end, 48 | filterText = function(trigger_char, issue) 49 | return string.format("%s %s %s", trigger_char, issue.number, issue.title) 50 | end, 51 | documentation = function(trigger_char, issue) 52 | return { 53 | kind = "markdown", 54 | value = string.format("# %s\n\n%s", issue.title, issue.body), 55 | } 56 | end, 57 | }, 58 | ---@type cmp_git.FormatConfig 59 | mentions = { 60 | label = function(trigger_char, mention) 61 | return string.format("@%s", mention.login) 62 | end, 63 | insertText = function(trigger_char, mention) 64 | return string.format("@%s", mention.login) 65 | end, 66 | filterText = function(trigger_char, mention) 67 | return string.format("@%s", mention.login) 68 | end, 69 | documentation = function(trigger_char, mention) 70 | return { 71 | kind = "markdown", 72 | value = string.format("# %s", mention.login), 73 | } 74 | end, 75 | }, 76 | ---@type cmp_git.FormatConfig 77 | pull_requests = { 78 | label = function(trigger_char, pr) 79 | return string.format("#%s: %s", pr.number, pr.title) 80 | end, 81 | insertText = function(trigger_char, pr) 82 | return string.format("#%s", pr.number) 83 | end, 84 | filterText = function(trigger_char, pr) 85 | return string.format("%s %s %s", trigger_char, pr.number, pr.title) 86 | end, 87 | documentation = function(trigger_char, pr) 88 | return { 89 | kind = "markdown", 90 | value = string.format("# %s\n\n%s", pr.title, pr.body), 91 | } 92 | end, 93 | }, 94 | }, 95 | gitlab = { 96 | ---@type cmp_git.FormatConfig 97 | issues = { 98 | label = function(trigger_char, issue) 99 | return string.format("#%s: %s", issue.iid, issue.title) 100 | end, 101 | insertText = function(trigger_char, issue) 102 | return string.format("#%s", issue.iid) 103 | end, 104 | filterText = function(trigger_char, issue) 105 | return string.format("%s %s %s", trigger_char, issue.iid, issue.title) 106 | end, 107 | documentation = function(trigger_char, issue) 108 | return { 109 | kind = "markdown", 110 | value = string.format("# %s\n\n%s", issue.title, issue.description), 111 | } 112 | end, 113 | }, 114 | ---@type cmp_git.FormatConfig 115 | mentions = { 116 | label = function(trigger_char, mention) 117 | return string.format("@%s", mention.username) 118 | end, 119 | insertText = function(trigger_char, mention) 120 | return string.format("@%s", mention.username) 121 | end, 122 | filterText = function(trigger_char, mention) 123 | return string.format("%s %s", trigger_char, mention.username) 124 | end, 125 | documentation = function(trigger_char, mention) 126 | return { 127 | kind = "markdown", 128 | value = string.format("# %s\n\n%s", mention.username, mention.name), 129 | } 130 | end, 131 | }, 132 | ---@type cmp_git.FormatConfig 133 | merge_requests = { 134 | label = function(trigger_char, mr) 135 | return string.format("!%s: %s", mr.iid, mr.title) 136 | end, 137 | insertText = function(trigger_char, mr) 138 | return string.format("!%s", mr.iid) 139 | end, 140 | filterText = function(trigger_char, mr) 141 | return string.format("%s %s %s", trigger_char, mr.iid, mr.title) 142 | end, 143 | documentation = function(trigger_char, mr) 144 | return { 145 | kind = "markdown", 146 | value = string.format("# %s\n\n%s", mr.title, mr.description), 147 | } 148 | end, 149 | }, 150 | }, 151 | } 152 | 153 | ---@class cmp_git.CompletionItem : lsp.CompletionItem 154 | ---@field label string 155 | ---@field filterText string 156 | ---@field insertText string 157 | ---@field sortText string 158 | ---@field documentation lsp.MarkupContent 159 | ---@field data any 160 | 161 | ---@generic TItem 162 | ---@param config { format: cmp_git.FormatConfig, sort_by: string | fun(item: TItem): string } 163 | ---@param trigger_char string 164 | ---@return cmp_git.CompletionItem 165 | function M.item(config, trigger_char, item) 166 | return { 167 | label = config.format.label(trigger_char, item), 168 | filterText = config.format.filterText(trigger_char, item), 169 | insertText = config.format.insertText(trigger_char, item), 170 | sortText = sort.get_sort_text(config.sort_by, item), 171 | documentation = config.format.documentation(trigger_char, item), 172 | data = item, 173 | } 174 | end 175 | 176 | return M 177 | -------------------------------------------------------------------------------- /lua/cmp_git/init.lua: -------------------------------------------------------------------------------- 1 | local Source = require("cmp_git.source") 2 | 3 | local M = {} 4 | 5 | ---@param overrides cmp_git.Config Can be a partial config 6 | function M.setup(overrides) 7 | require("cmp").register_source("git", Source.new(overrides)) 8 | end 9 | 10 | return M 11 | -------------------------------------------------------------------------------- /lua/cmp_git/log.lua: -------------------------------------------------------------------------------- 1 | return require("plenary.log").new({ 2 | plugin = "cmp-git", 3 | }) 4 | -------------------------------------------------------------------------------- /lua/cmp_git/sort.lua: -------------------------------------------------------------------------------- 1 | local utils = require("cmp_git.utils") 2 | 3 | local M = { 4 | git = { 5 | ---@param commit cmp_git.Commit 6 | commits = function(commit) -- nil, "sha", "title", "description", "author_name", "author_email", "commit_timestamp", or custom function 7 | return string.format("%010d", commit.diff) 8 | end, 9 | }, 10 | github = { 11 | ---@param issue cmp_git.GitHub.Issue 12 | issues = function(issue) -- nil, "number", "title", "body", or custom function 13 | return string.format("%010d", os.difftime(os.time(), utils.parse_github_date(issue.updatedAt))) 14 | end, 15 | mentions = nil, -- nil, "login", or custom function 16 | ---@param pr cmp_git.GitHub.PullRequest 17 | pull_requests = function(pr) -- nil, "number", "title", "body", or custom function 18 | return string.format("%010d", os.difftime(os.time(), utils.parse_github_date(pr.updatedAt))) 19 | end, 20 | }, 21 | gitlab = { 22 | issues = function(issue) -- nil, "iid", "title", "description", or custom function 23 | return string.format("%010d", os.difftime(os.time(), utils.parse_gitlab_date(issue.updated_at))) 24 | end, 25 | mentions = nil, -- nil, "username", "name", or custom function 26 | merge_requests = function(mr) -- nil, "iid", "title", "description", or custom function 27 | return string.format("%010d", os.difftime(os.time(), utils.parse_gitlab_date(mr.updated_at))) 28 | end, 29 | }, 30 | } 31 | 32 | ---@generic TItem 33 | ---@param config_val string | (fun(item: TItem): string) 34 | ---@param item TItem 35 | ---@return string? 36 | function M.get_sort_text(config_val, item) 37 | if type(config_val) == "function" then 38 | return config_val(item) 39 | elseif type(config_val) == "string" then 40 | return item[config_val] 41 | end 42 | 43 | return nil 44 | end 45 | 46 | return M 47 | -------------------------------------------------------------------------------- /lua/cmp_git/source.lua: -------------------------------------------------------------------------------- 1 | local github = require("cmp_git.sources.github") 2 | local gitlab = require("cmp_git.sources.gitlab") 3 | local git = require("cmp_git.sources.git") 4 | local utils = require("cmp_git.utils") 5 | 6 | local Source = { 7 | ---@type cmp_git.Config 8 | ---@diagnostic disable-next-line: missing-fields 9 | config = {}, 10 | ---@type table 11 | filetypes = {}, 12 | ---@type cmp_git.Sources 13 | ---@diagnostic disable-next-line: missing-fields 14 | sources = {}, 15 | ---@type cmp_git.Config.TriggerAction[] 16 | trigger_actions = {}, 17 | ---@type string[] 18 | trigger_characters = {}, 19 | } 20 | 21 | ---@class cmp_git.Sources 22 | ---@field git cmp_git.Source.Git 23 | ---@field gitlab cmp_git.Source.Gitlab 24 | ---@field github cmp_git.Source.GitHub 25 | 26 | function Source.new(overrides) 27 | local self = setmetatable({}, { 28 | __index = Source, 29 | }) 30 | 31 | self.config = vim.tbl_extend("force", require("cmp_git.config"), overrides or {}) 32 | for _, item in ipairs(self.config.filetypes) do 33 | self.filetypes[item] = true 34 | end 35 | 36 | self.sources.git = git.new(self.config.git) 37 | self.sources.gitlab = gitlab.new(self.config.gitlab) 38 | self.sources.github = github.new(self.config.github) 39 | 40 | for _, v in pairs(self.config.trigger_actions) do 41 | if not vim.tbl_contains(self.trigger_characters, v.trigger_character) then 42 | table.insert(self.trigger_characters, v.trigger_character) 43 | end 44 | end 45 | 46 | self.trigger_characters_str = table.concat(self.trigger_characters, "") 47 | self.keyword_pattern = string.format("[%s]\\S*", self.trigger_characters_str) 48 | 49 | self.trigger_actions = self.config.trigger_actions 50 | 51 | return self 52 | end 53 | 54 | ---@class cmp_git.CompletionList : lsp.CompletionList 55 | ---@field items cmp_git.CompletionItem[] 56 | 57 | ---@param params cmp.SourceCompletionApiParams 58 | ---@param callback fun(args: cmp_git.CompletionList) 59 | function Source:_complete(params, callback) 60 | ---@type string? 61 | local trigger_character = nil 62 | 63 | if params.completion_context.triggerKind == 1 then 64 | trigger_character = 65 | string.match(params.context.cursor_before_line, "%s*([" .. self.trigger_characters_str .. "])%S*$") 66 | elseif params.completion_context.triggerKind == 2 then 67 | trigger_character = params.completion_context.triggerCharacter 68 | end 69 | 70 | utils.get_git_info(self.config.remotes, { 71 | enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, 72 | ssh_aliases = self.config.ssh_aliases, 73 | on_complete = function(git_info) 74 | for _, trigger in pairs(self.trigger_actions) do 75 | if trigger.trigger_character == trigger_character then 76 | if trigger.action(self.sources, trigger_character, callback, params, git_info) then 77 | break 78 | end 79 | end 80 | end 81 | end, 82 | }) 83 | end 84 | 85 | ---@module 'cmp' 86 | ---@param params cmp.SourceCompletionApiParams 87 | ---@param callback fun(args: cmp_git.CompletionList) 88 | function Source:complete(params, callback) 89 | utils.is_git_repo(function(is_git_repo) 90 | if not is_git_repo then 91 | return 92 | end 93 | self:_complete(params, callback) 94 | end) 95 | end 96 | 97 | function Source:get_keyword_pattern() 98 | return self.keyword_pattern 99 | end 100 | 101 | function Source:get_trigger_characters() 102 | return self.trigger_characters 103 | end 104 | 105 | function Source:get_debug_name() 106 | return "git" 107 | end 108 | 109 | function Source:is_available() 110 | if self.filetypes["*"] ~= nil or self.filetypes[vim.bo.filetype] ~= nil then 111 | return true 112 | end 113 | 114 | -- split filetype on period to support multi-filetype buffers (see `:h 'filetype'`) 115 | -- 116 | -- the pattern captures all non-period characters 117 | for ft in string.gmatch(vim.bo.filetype, "[^%.]*") do 118 | if self.filetypes[ft] ~= nil then 119 | return true 120 | end 121 | end 122 | 123 | return false 124 | end 125 | 126 | return Source 127 | -------------------------------------------------------------------------------- /lua/cmp_git/sources/git.lua: -------------------------------------------------------------------------------- 1 | local Job = require("plenary.job") 2 | local log = require("cmp_git.log") 3 | local format = require("cmp_git.format") 4 | 5 | ---@class cmp_git.Source.Git 6 | local Git = { 7 | ---@type table 8 | cache_commits = {}, 9 | ---@type cmp_git.Config.Git 10 | ---@diagnostic disable-next-line: missing-fields 11 | config = {}, 12 | } 13 | 14 | ---@param overrides cmp_git.Config.Git 15 | function Git.new(overrides) 16 | local self = setmetatable({}, { 17 | __index = Git, 18 | }) 19 | 20 | self.config = vim.tbl_deep_extend("force", require("cmp_git.config").git, overrides or {}) 21 | 22 | if overrides.filter_fn then 23 | self.config.format.filterText = overrides.filter_fn 24 | end 25 | 26 | return self 27 | end 28 | 29 | ---@param s string 30 | local function trim(s) 31 | return (s:gsub("^%s*(.-)%s*$", "%1")) 32 | end 33 | 34 | ---@param input string 35 | ---@param sep string 36 | ---@return string[] 37 | local function split_by(input, sep) 38 | local t = {} 39 | 40 | while true do 41 | local s, e = string.find(input, sep) 42 | 43 | if not s then 44 | break 45 | end 46 | 47 | local part = string.sub(input, 1, s - 1) 48 | input = string.sub(input, e + 1) 49 | 50 | table.insert(t, part) 51 | end 52 | 53 | return t 54 | end 55 | 56 | ---@param commits cmp_git.CompletionItem[] 57 | local function update_edit_range(commits, cursor, _offset) 58 | for k, v in pairs(commits) do 59 | local sha = v.insertText 60 | 61 | local update = { 62 | range = { 63 | start = { 64 | line = cursor.row - 1, 65 | character = cursor.character - 1, 66 | }, 67 | ["end"] = { 68 | line = cursor.row - 1, 69 | character = cursor.character + string.len(sha), 70 | }, 71 | }, 72 | newText = sha, 73 | } 74 | 75 | commits[k].textEdit = update 76 | end 77 | end 78 | 79 | ---@param trigger_char string 80 | ---@param callback fun(commits: cmp_git.CompletionItem[]) 81 | ---@param config cmp_git.Config.GitCommits 82 | local function parse_commits(trigger_char, callback, config) 83 | -- Choose unique and long end markers 84 | local end_part_marker = "###CMP_GIT###" 85 | local end_entry_marker = "###CMP_GIT_END###" 86 | 87 | -- Extract abbreviated commit sha, subject, body, author name, author email, commit timestamp 88 | ---@diagnostic disable-next-line: missing-fields 89 | local job = Job:new({ 90 | command = "git", 91 | args = { 92 | "log", 93 | "-n", 94 | config.limit, 95 | "--date=unix", 96 | string.format( 97 | "--pretty=format:%%H%s%%s%s%%b%s%%cn%s%%ce%s%%cd%s%s", 98 | end_part_marker, 99 | end_part_marker, 100 | end_part_marker, 101 | end_part_marker, 102 | end_part_marker, 103 | end_part_marker, 104 | end_entry_marker 105 | ), 106 | }, 107 | on_exit = vim.schedule_wrap(function(job, code) 108 | if code ~= 0 then 109 | log.fmt_debug("%s returned with exit code %d", "git", code) 110 | else 111 | log.fmt_debug("%s returned with a result", "git") 112 | local result = table.concat(job:result(), "") 113 | 114 | local commits = {} 115 | 116 | local entries = split_by(result, end_entry_marker) 117 | 118 | for _, e in ipairs(entries) do 119 | local part = split_by(e, end_part_marker) 120 | 121 | local sha = trim(part[1]):sub(0, config.sha_length) 122 | local title = trim(part[2]) 123 | local description = trim(part[3]) or "" 124 | local author_name = part[4] or "" 125 | local author_mail = part[5] or "" 126 | local commit_timestamp = part[6] or "" 127 | local diff = os.difftime(os.time(), commit_timestamp) 128 | 129 | ---@class cmp_git.Commit 130 | local commit = { 131 | sha = sha, 132 | title = title, 133 | description = description, 134 | author_name = author_name, 135 | author_mail = author_mail, 136 | commit_timestamp = commit_timestamp, 137 | diff = diff, 138 | } 139 | 140 | table.insert(commits, format.item(config, trigger_char, commit)) 141 | end 142 | 143 | callback(commits) 144 | end 145 | end), 146 | }) 147 | 148 | job:start() 149 | end 150 | 151 | ---@param callback fun(commits: cmp_git.CompletionList) 152 | ---@param trigger_char string 153 | function Git:get_commits(callback, params, trigger_char) 154 | local config = self.config.commits 155 | local cursor = params.context.cursor 156 | local offset = params.offset 157 | 158 | local bufnr = vim.api.nvim_get_current_buf() 159 | 160 | if self.cache_commits and self.cache_commits[bufnr] then 161 | local commits = self.cache_commits[bufnr] 162 | update_edit_range(commits, cursor, offset) 163 | callback({ items = commits, isIncomplete = false }) 164 | else 165 | parse_commits(trigger_char, function(commits) 166 | update_edit_range(commits, cursor, offset) 167 | callback({ items = commits, isIncomplete = false }) 168 | end, config) 169 | end 170 | 171 | return true 172 | end 173 | 174 | return Git 175 | -------------------------------------------------------------------------------- /lua/cmp_git/sources/github.lua: -------------------------------------------------------------------------------- 1 | local Job = require("plenary.job") 2 | local utils = require("cmp_git.utils") 3 | local log = require("cmp_git.log") 4 | local format = require("cmp_git.format") 5 | 6 | ---@class cmp_git.AsyncItemList 7 | ---@field in_progress boolean 8 | ---@field items cmp_git.CompletionItem[] 9 | 10 | ---@class cmp_git.Source.GitHub 11 | local GitHub = { 12 | cache = { 13 | ---@type table 14 | issues = {}, 15 | ---@type table 16 | mentions = {}, 17 | ---@type table 18 | pull_requests = {}, 19 | }, 20 | ---@type cmp_git.Config.GitHub 21 | ---@diagnostic disable-next-line: missing-fields 22 | config = {}, 23 | } 24 | 25 | ---@param overrides cmp_git.Config.GitHub 26 | function GitHub.new(overrides) 27 | local self = setmetatable({}, { 28 | __index = GitHub, 29 | }) 30 | 31 | self.config = vim.tbl_deep_extend("force", require("cmp_git.config").github, overrides or {}) 32 | 33 | if overrides.filter_fn then 34 | self.config.format.filterText = overrides.filter_fn 35 | end 36 | 37 | table.insert(self.config.hosts, "github.com") 38 | GitHub.config = self.config 39 | return self 40 | end 41 | 42 | -- build a github api url 43 | ---@param git_host string 44 | ---@param path string 45 | local function github_url(git_host, path) 46 | if git_host == "github.com" then 47 | return string.format("https://api.github.com/%s", path) 48 | else 49 | return string.format("https://%s/api/v3/%s", git_host, path) 50 | end 51 | end 52 | 53 | ---@return table 54 | local function get_gh_env() 55 | return { 56 | GITHUB_API_TOKEN = vim.fn.getenv("GITHUB_API_TOKEN"), 57 | CLICOLOR = 0, -- disables color output to avoid parsing errors 58 | } 59 | end 60 | 61 | ---@return string[] 62 | local function get_curl_args(curl_url) 63 | local curl_args = { 64 | "-s", 65 | "-L", 66 | "-H", 67 | "'Accept: application/vnd.github.v3+json'", 68 | curl_url, 69 | } 70 | 71 | if vim.fn.exists("$GITHUB_API_TOKEN") == 1 then 72 | local token = vim.fn.getenv("GITHUB_API_TOKEN") 73 | local authorization_header = string.format("Authorization: token %s", token) 74 | table.insert(curl_args, "-H") 75 | table.insert(curl_args, authorization_header) 76 | end 77 | 78 | return curl_args 79 | end 80 | 81 | ---Used for fetching non-list data from GitHub 82 | ---@param callback fun(result: string, success: boolean): nil 83 | ---@param gh_args string[] 84 | ---@param curl_url string 85 | ---@return nil 86 | local function fetch_data(callback, gh_args, curl_url) 87 | local gh_job = utils.build_simple_job("gh", gh_args, get_gh_env(), callback) 88 | 89 | local curl_job = utils.build_simple_job("curl", get_curl_args(curl_url), nil, callback) 90 | 91 | utils.chain_fallback(gh_job, curl_job):start() 92 | end 93 | 94 | ---@generic TItem 95 | ---@param callback fun(list: cmp_git.CompletionList) 96 | ---@param gh_args string[] 97 | ---@param curl_url string 98 | ---@param handle_item fun(item: TItem): cmp_git.CompletionItem 99 | ---@param handle_parsed? fun(parsed: any): TItem[] 100 | local function get_items(callback, gh_args, curl_url, handle_item, handle_parsed) 101 | local gh_job = utils.build_job("gh", gh_args, get_gh_env(), callback, handle_item, handle_parsed) 102 | 103 | local curl_job = utils.build_job("curl", get_curl_args(curl_url), nil, callback, handle_item, handle_parsed) 104 | 105 | return utils.chain_fallback(gh_job, curl_job) 106 | end 107 | 108 | ---Reference: https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests 109 | ---@class cmp_git.GitHub.PullRequest 110 | ---@field number integer 111 | ---@field title string 112 | ---@field body string 113 | ---@field updatedAt string 114 | 115 | ---@param callback fun(list: cmp_git.CompletionList) 116 | ---@param git_info cmp_git.GitInfo 117 | ---@param trigger_char string 118 | ---@param config cmp_git.Config.GitHub.PullRequest 119 | local function get_pull_requests_job(callback, git_info, trigger_char, config) 120 | return get_items( 121 | callback, 122 | { 123 | "pr", 124 | "list", 125 | "--repo", 126 | string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo), 127 | "--limit", 128 | config.limit, 129 | "--state", 130 | config.state, 131 | "--json", 132 | table.concat(config.fields, ","), 133 | }, 134 | github_url( 135 | git_info.host, 136 | string.format( 137 | "repos/%s/%s/pulls?state=%s&per_page=%d&page=%d", 138 | git_info.owner, 139 | git_info.repo, 140 | config.state, 141 | config.limit, 142 | 1 143 | ) 144 | ), 145 | function(pr) 146 | if pr.body ~= vim.NIL then 147 | pr.body = string.gsub(pr.body or "", "\r", "") 148 | else 149 | pr.body = "" 150 | end 151 | 152 | if not pr.updatedAt then 153 | pr.updatedAt = pr.updated_at 154 | end 155 | 156 | return format.item(config, trigger_char, pr) 157 | end 158 | ) 159 | end 160 | 161 | ---Reference: https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues 162 | ---@class cmp_git.GitHub.Issue 163 | ---@field number integer 164 | ---@field title string 165 | ---@field body string 166 | ---@field updatedAt string 167 | ---@field updated_at string 168 | 169 | ---@param callback fun(list: cmp_git.CompletionList) 170 | ---@param git_info cmp_git.GitInfo 171 | ---@param trigger_char string 172 | ---@param config cmp_git.Config.GitHub.Issue 173 | local function get_issues_job(callback, git_info, trigger_char, config) 174 | local gh_args = { 175 | "issue", 176 | "list", 177 | "--repo", 178 | string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo), 179 | "--limit", 180 | config.limit, 181 | "--state", 182 | config.state, 183 | "--json", 184 | table.concat(config.fields, ","), 185 | } 186 | local curl_path = string.format( 187 | "repos/%s/%s/issues?state=%s&per_page=%d&page=%d", 188 | git_info.owner, 189 | git_info.repo, 190 | config.state, 191 | config.limit, 192 | 1 193 | ) 194 | if config.filter == "mentioned" then 195 | gh_args = vim.list_extend(gh_args, { "--mention", "@me" }) 196 | curl_path = string.format("%s&mentioned=@me", curl_path) 197 | elseif config.filter == "assigned" then 198 | gh_args = vim.list_extend(gh_args, { "--assignee", "@me" }) 199 | curl_path = string.format("%s&assignee=@me", curl_path) 200 | elseif config.filter == "created" then 201 | gh_args = vim.list_extend(gh_args, { "--author", "@me" }) 202 | curl_path = string.format("%s&creator=@me", curl_path) 203 | end 204 | return get_items( 205 | callback, 206 | gh_args, 207 | github_url(git_info.host, curl_path), 208 | function(issue) ---@param issue cmp_git.GitHub.Issue 209 | if issue.body ~= vim.NIL then 210 | issue.body = string.gsub(issue.body or "", "\r", "") 211 | else 212 | issue.body = "" 213 | end 214 | 215 | if not issue.updatedAt then 216 | issue.updatedAt = issue.updated_at 217 | end 218 | 219 | return format.item(config, trigger_char, issue) 220 | end 221 | ) 222 | end 223 | 224 | ---@param git_info cmp_git.GitInfo 225 | local function use_gh_default_repo_if_set(git_info) 226 | local gh_default_repo = vim.fn.system({ "gh", "repo", "set-default", "--view" }) 227 | if vim.v.shell_error ~= 0 then 228 | return git_info 229 | end 230 | local owner, repo = string.match(vim.fn.trim(gh_default_repo), "^(.+)/(.+)$") 231 | if owner ~= nil and repo ~= nil then 232 | git_info.owner = owner 233 | git_info.repo = repo 234 | end 235 | return git_info 236 | end 237 | 238 | ---@param git_info cmp_git.GitInfo 239 | function GitHub:is_valid_host(git_info) 240 | if 241 | git_info.host == nil 242 | or git_info.owner == nil 243 | or git_info.repo == nil 244 | or not vim.tbl_contains(GitHub.config.hosts, git_info.host) 245 | then 246 | return false 247 | end 248 | return true 249 | end 250 | 251 | ---@param callback fun(list: cmp_git.CompletionList) 252 | ---@param git_info cmp_git.GitInfo 253 | ---@param trigger_char string 254 | function GitHub:_get_issues(callback, git_info, trigger_char) 255 | local config = self.config.issues 256 | local bufnr = vim.api.nvim_get_current_buf() 257 | 258 | if self.cache.issues[bufnr] then 259 | callback({ items = self.cache.issues[bufnr], isIncomplete = false }) 260 | return nil 261 | end 262 | 263 | local issues_job = get_issues_job(function(args) 264 | self.cache.issues[bufnr] = args.items 265 | callback(args) 266 | end, git_info, trigger_char, config) 267 | 268 | return issues_job 269 | end 270 | 271 | ---@param callback fun(list: cmp_git.CompletionList) 272 | ---@param git_info cmp_git.GitInfo 273 | ---@param trigger_char string 274 | function GitHub:_get_pull_requests(callback, git_info, trigger_char) 275 | local config = self.config.pull_requests 276 | local bufnr = vim.api.nvim_get_current_buf() 277 | 278 | if self.cache.pull_requests[bufnr] then 279 | callback({ items = self.cache.pull_requests[bufnr], isIncomplete = false }) 280 | return nil 281 | end 282 | 283 | local pr_job = get_pull_requests_job(function(args) 284 | self.cache.pull_requests[bufnr] = args.items 285 | callback(args) 286 | end, git_info, trigger_char, config) 287 | 288 | return pr_job 289 | end 290 | 291 | ---@param callback fun(list: cmp_git.CompletionList) 292 | ---@param git_info cmp_git.GitInfo 293 | ---@param trigger_char string 294 | function GitHub:get_issues(callback, git_info, trigger_char) 295 | if not GitHub:is_valid_host(git_info) then 296 | return false 297 | end 298 | 299 | git_info = use_gh_default_repo_if_set(git_info) 300 | 301 | local job = self:_get_issues(callback, git_info, trigger_char) 302 | 303 | if job then 304 | job:start() 305 | end 306 | 307 | return true 308 | end 309 | 310 | ---@param callback fun(list: cmp_git.CompletionList) 311 | ---@param git_info cmp_git.GitInfo 312 | ---@param trigger_char string 313 | function GitHub:get_pull_requests(callback, git_info, trigger_char) 314 | if not GitHub:is_valid_host(git_info) then 315 | return false 316 | end 317 | 318 | git_info = use_gh_default_repo_if_set(git_info) 319 | 320 | local job = self:_get_pull_requests(callback, git_info, trigger_char) 321 | 322 | if job then 323 | job:start() 324 | end 325 | 326 | return true 327 | end 328 | 329 | ---@param callback fun(list: cmp_git.CompletionList) 330 | ---@param git_info cmp_git.GitInfo 331 | ---@param trigger_char string 332 | function GitHub:get_issues_and_prs(callback, git_info, trigger_char) 333 | if not GitHub:is_valid_host(git_info) then 334 | return false 335 | end 336 | 337 | git_info = use_gh_default_repo_if_set(git_info) 338 | 339 | local bufnr = vim.api.nvim_get_current_buf() 340 | 341 | if self.cache.issues[bufnr] and self.cache.pull_requests[bufnr] then 342 | local issues = self.cache.issues[bufnr] 343 | local prs = self.cache.pull_requests[bufnr] 344 | 345 | ---@type cmp_git.CompletionItem[] 346 | local items = {} 347 | items = vim.list_extend(items, issues) 348 | items = vim.list_extend(items, prs) 349 | 350 | log.fmt_debug("Got %d issues and prs from cache", #items) 351 | callback({ items = issues, isIncomplete = false }) 352 | else 353 | ---@type cmp_git.CompletionItem[] 354 | local items = {} 355 | 356 | local issues_job = self:_get_issues(function(args) 357 | items = args.items 358 | self.cache.issues[bufnr] = args.items 359 | end, git_info, trigger_char) 360 | 361 | local pull_requests_job = self:_get_pull_requests(function(args) 362 | local prs = args.items 363 | self.cache.pull_requests[bufnr] = args.items 364 | 365 | items = vim.list_extend(items, prs) 366 | 367 | log.fmt_debug("Got %d issues and prs from GitHub", #items) 368 | callback({ items = items, isIncomplete = false }) 369 | end, git_info, trigger_char) 370 | 371 | Job.chain(issues_job, pull_requests_job) 372 | end 373 | 374 | return true 375 | end 376 | 377 | ---Reference: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repository-contributors 378 | ---@class cmp_git.GitHub.Mention 379 | ---@field login string 380 | 381 | ---@param callback fun(list: cmp_git.CompletionList): nil 382 | ---@param git_info cmp_git.GitInfo 383 | ---@param trigger_char string 384 | ---@param member_type 'collaborators' | 'contributors' 385 | function GitHub:_get_mentions(callback, git_info, trigger_char, member_type) 386 | local config = self.config.mentions 387 | local bufnr = vim.api.nvim_get_current_buf() 388 | 389 | ---@param page integer 390 | local function fetch_mentions(page) 391 | local page_size = math.min(config.limit - #self.cache.mentions[bufnr].items, 100) 392 | local job = get_items( 393 | function(args) 394 | local mentionsCache = self.cache.mentions[bufnr] 395 | vim.list_extend(mentionsCache.items, args.items) 396 | -- Go until there are no more items or we've reached the limit 397 | mentionsCache.in_progress = #args.items ~= 0 and #mentionsCache.items < config.limit 398 | if mentionsCache.in_progress then 399 | fetch_mentions(page + 1) 400 | end 401 | -- Do not wait for all pages to be fetched to give back results 402 | callback({ items = mentionsCache.items, isIncomplete = mentionsCache.in_progress }) 403 | end, 404 | { 405 | "api", 406 | string.format( 407 | "repos/%s/%s/%s?per_page=%d&page=%d", 408 | git_info.owner, 409 | git_info.repo, 410 | member_type, 411 | page_size, 412 | page 413 | ), 414 | "--hostname", 415 | git_info.host, 416 | }, 417 | github_url( 418 | git_info.host, 419 | string.format( 420 | "%s/%s/%s?per_page=%d&page=%d", 421 | git_info.owner, 422 | git_info.repo, 423 | member_type, 424 | page_size, 425 | page 426 | ) 427 | ), 428 | ---@param mention cmp_git.GitHub.Mention 429 | function(mention) 430 | return format.item(config, trigger_char, mention) 431 | end, 432 | function(parsed) 433 | if parsed["mentionableUsers"] then 434 | return parsed["mentionableUsers"] 435 | end 436 | return parsed 437 | end 438 | ) 439 | job:start() 440 | end 441 | 442 | fetch_mentions(1) 443 | end 444 | 445 | ---@param callback fun(list: cmp_git.CompletionList) 446 | ---@param git_info cmp_git.GitInfo 447 | ---@param trigger_char string 448 | ---@return boolean 449 | function GitHub:get_mentions(callback, git_info, trigger_char) 450 | if not GitHub:is_valid_host(git_info) then 451 | return false 452 | end 453 | 454 | local bufnr = vim.api.nvim_get_current_buf() 455 | 456 | if self.cache.mentions[bufnr] then 457 | local mentionsCache = self.cache.mentions[bufnr] 458 | -- Immediately return in progress results to prevent multiple concurrent requests 459 | callback({ items = mentionsCache.items, isIncomplete = mentionsCache.in_progress }) 460 | return true 461 | end 462 | 463 | self.cache.mentions[bufnr] = { items = {}, in_progress = true } 464 | fetch_data( 465 | function(result) 466 | local ok, parsed = pcall(vim.json.decode, result) 467 | local member_type = "contributors" 468 | if ok then 469 | -- If the user has permission to see the collaborators, use the collaborators endpoint 470 | -- Note that "404" is considered a success, since the dummy user likely doesn't exist 471 | member_type = (parsed.status ~= "403" and parsed.status ~= "401") and "collaborators" or "contributors" 472 | end 473 | self:_get_mentions(callback, git_info, trigger_char, member_type) 474 | end, 475 | { 476 | "api", 477 | -- Using a dummy username to check if the user has permission to see the collaborators 478 | string.format("repos/%s/%s/collaborators/testing/permission", git_info.owner, git_info.repo), 479 | "--hostname", 480 | git_info.host, 481 | }, 482 | github_url( 483 | git_info.host, 484 | string.format("repos/%s/%s/collaborators/testing/permission", git_info.owner, git_info.repo) 485 | ) 486 | ) 487 | 488 | return true 489 | end 490 | 491 | return GitHub 492 | -------------------------------------------------------------------------------- /lua/cmp_git/sources/gitlab.lua: -------------------------------------------------------------------------------- 1 | local utils = require("cmp_git.utils") 2 | local log = require("cmp_git.log") 3 | local format = require("cmp_git.format") 4 | 5 | ---@class cmp_git.Source.Gitlab 6 | local GitLab = { 7 | cache = { 8 | ---@type table 9 | issues = {}, 10 | ---@type table 11 | mentions = {}, 12 | ---@type table 13 | merge_requests = {}, 14 | }, 15 | ---@type cmp_git.Config.Gitlab 16 | ---@diagnostic disable-next-line: missing-fields 17 | config = {}, 18 | } 19 | 20 | ---@param overrides cmp_git.Config.Gitlab 21 | function GitLab.new(overrides) 22 | local self = setmetatable({}, { 23 | __index = GitLab, 24 | }) 25 | 26 | self.config = vim.tbl_deep_extend("force", require("cmp_git.config").gitlab, overrides or {}) 27 | 28 | if overrides.filter_fn then 29 | self.config.format.filterText = overrides.filter_fn 30 | end 31 | 32 | table.insert(self.config.hosts, "gitlab.com") 33 | GitLab.config = self.config 34 | return self 35 | end 36 | 37 | ---@param git_info cmp_git.GitInfo 38 | local function get_project_id(git_info) 39 | return utils.url_encode(string.format("%s/%s", git_info.owner, git_info.repo)) 40 | end 41 | 42 | ---@param callback fun(list: cmp_git.CompletionList) 43 | ---@param handle_item fun(item: any): cmp_git.CompletionItem 44 | local function get_items(callback, glab_args, curl_url, handle_item) 45 | local glab_job = utils.build_job("glab", glab_args, { 46 | GITLAB_TOKEN = vim.fn.getenv("GITLAB_TOKEN"), 47 | NO_COLOR = 1, -- disables color output to avoid parsing errors 48 | }, callback, handle_item) 49 | 50 | local curl_args = { 51 | "-s", 52 | curl_url, 53 | } 54 | 55 | if vim.fn.exists("$GITLAB_TOKEN") == 1 then 56 | local token = vim.fn.getenv("GITLAB_TOKEN") 57 | local authorization_header = string.format("Authorization: Bearer %s", token) 58 | table.insert(curl_args, "-H") 59 | table.insert(curl_args, authorization_header) 60 | end 61 | 62 | local curl_job = utils.build_job("curl", curl_args, nil, callback, handle_item) 63 | 64 | return utils.chain_fallback(glab_job, curl_job) 65 | end 66 | 67 | ---@param git_info cmp_git.GitInfo 68 | function GitLab:is_valid_host(git_info) 69 | if 70 | git_info.host == nil 71 | or git_info.owner == nil 72 | or git_info.repo == nil 73 | or not vim.tbl_contains(GitLab.config.hosts, git_info.host) 74 | then 75 | return false 76 | end 77 | return true 78 | end 79 | 80 | ---@param callback fun(list: cmp_git.CompletionList) 81 | ---@param git_info cmp_git.GitInfo 82 | ---@param trigger_char string 83 | function GitLab:get_issues(callback, git_info, trigger_char) 84 | if not GitLab:is_valid_host(git_info) then 85 | return false 86 | end 87 | 88 | local config = self.config.issues 89 | local bufnr = vim.api.nvim_get_current_buf() 90 | 91 | if self.cache.issues[bufnr] then 92 | local items = self.cache.issues[bufnr] 93 | log.fmt_debug("Got %d issues from cache", #items) 94 | callback({ items = items, isIncomplete = false }) 95 | return true 96 | end 97 | 98 | local id = get_project_id(git_info) 99 | 100 | local job = get_items( 101 | function(args) 102 | log.fmt_debug("Got %d issues from GitLab", #args.items) 103 | callback(args) 104 | self.cache.issues[bufnr] = args.items 105 | end, 106 | { 107 | "api", 108 | string.format("/projects/%s/issues?per_page=%d&state=%s", id, config.limit, config.state), 109 | }, 110 | string.format( 111 | "https://%s/api/v4/projects/%s/issues?per_page=%d&state=%s", 112 | git_info.host, 113 | id, 114 | config.limit, 115 | config.state 116 | ), 117 | function(issue) 118 | if issue.description == vim.NIL then 119 | issue.description = "" 120 | end 121 | 122 | return format.item(config, trigger_char, issue) 123 | end 124 | ) 125 | job:start() 126 | return true 127 | end 128 | 129 | ---@param callback fun(list: cmp_git.CompletionList) 130 | ---@param git_info cmp_git.GitInfo 131 | ---@param trigger_char string 132 | function GitLab:get_mentions(callback, git_info, trigger_char) 133 | if not GitLab:is_valid_host(git_info) then 134 | return false 135 | end 136 | 137 | local config = self.config.mentions 138 | local bufnr = vim.api.nvim_get_current_buf() 139 | 140 | if self.cache.mentions[bufnr] then 141 | callback({ items = self.cache.mentions[bufnr], isIncomplete = false }) 142 | return true 143 | end 144 | 145 | local id = get_project_id(git_info) 146 | 147 | local job = get_items( 148 | function(args) 149 | callback(args) 150 | self.cache.mentions[bufnr] = args.items 151 | end, 152 | { 153 | "api", 154 | string.format("/projects/%s/users?per_page=%d", id, config.limit), 155 | }, 156 | string.format("https://%s/api/v4/projects/%s/users?per_page=%d", git_info.host, id, config.limit), 157 | function(mention) 158 | return format.item(config, trigger_char, mention) 159 | end 160 | ) 161 | job:start() 162 | 163 | return true 164 | end 165 | 166 | ---@param callback fun(list: cmp_git.CompletionList) 167 | ---@param git_info cmp_git.GitInfo 168 | ---@param trigger_char string 169 | function GitLab:get_merge_requests(callback, git_info, trigger_char) 170 | if not GitLab:is_valid_host(git_info) then 171 | return false 172 | end 173 | 174 | local config = self.config.merge_requests 175 | local bufnr = vim.api.nvim_get_current_buf() 176 | 177 | if self.cache.merge_requests[bufnr] then 178 | local items = self.cache.merge_requests[bufnr] 179 | log.fmt_debug("Got %d MRs from cache", #items) 180 | callback({ items = items, isIncomplete = false }) 181 | return true 182 | end 183 | 184 | local id = get_project_id(git_info) 185 | 186 | local job = get_items( 187 | function(args) 188 | log.fmt_debug("Got %d MRs from GitLab", #args.items) 189 | callback(args) 190 | self.cache.merge_requests[bufnr] = args.items 191 | end, 192 | { 193 | "api", 194 | string.format("/projects/%s/merge_requests?per_page=%d&state=%s", id, config.limit, config.state), 195 | }, 196 | string.format( 197 | "https://%s/api/v4/projects/%s/merge_requests?per_page=%d&state=%s", 198 | git_info.host, 199 | id, 200 | config.limit, 201 | config.state 202 | ), 203 | 204 | function(mr) 205 | return format.item(config, trigger_char, mr) 206 | end 207 | ) 208 | job:start() 209 | 210 | return true 211 | end 212 | 213 | return GitLab 214 | -------------------------------------------------------------------------------- /lua/cmp_git/utils.lua: -------------------------------------------------------------------------------- 1 | local log = require("cmp_git.log") 2 | local Job = require("plenary.job") 3 | 4 | local M = {} 5 | 6 | ---@param c integer|string 7 | local function char_to_hex(c) 8 | return string.format("%%%02X", string.byte(c)) 9 | end 10 | 11 | ---@param cmd string 12 | ---@param opts { on_complete: fun(success: boolean, output: string[]): nil; cwd?: string } 13 | ---@return nil 14 | local function run_cmd_async(cmd, opts) 15 | ---@type string[] 16 | local output = {} 17 | vim.fn.jobstart(cmd, { 18 | on_stdout = function(_, data) 19 | if not data then 20 | return 21 | end 22 | vim.list_extend(output, data) 23 | end, 24 | on_stderr = function(_, data) 25 | if not data then 26 | return 27 | end 28 | vim.list_extend(output, data) 29 | end, 30 | on_exit = function(_, exit_code) 31 | opts.on_complete(exit_code == 0, output) 32 | end, 33 | cwd = opts.cwd, 34 | }) 35 | end 36 | 37 | ---@param value string 38 | function M.url_encode(value) 39 | return string.gsub(value, "([^%w _%%%-%.~])", char_to_hex) 40 | end 41 | 42 | ---@param d string 43 | function M.parse_gitlab_date(d) 44 | local year, month, day, hours, mins, secs, _, offsethours, offsetmins = 45 | d:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)%.(%d+)[+-](%d+):(%d+)") 46 | 47 | if hours == nil then 48 | year, month, day, hours, mins, secs = d:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)%.(%d+)Z") 49 | offsethours = 0 50 | offsetmins = 0 51 | end 52 | 53 | return os.time({ 54 | year = year, 55 | month = month, 56 | day = day, 57 | hour = hours + offsethours, 58 | min = mins + offsetmins, 59 | sec = secs, 60 | }) 61 | end 62 | 63 | ---@param d string 64 | function M.parse_github_date(d) 65 | local year, month, day, hours, mins, secs = d:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z") 66 | 67 | return os.time({ 68 | year = year, 69 | month = month, 70 | day = day, 71 | hour = hours, 72 | min = mins, 73 | sec = secs, 74 | }) 75 | end 76 | 77 | ---@param on_result fun(is_git_repo: boolean): nil 78 | ---@return nil 79 | function M.is_git_repo(on_result) 80 | local cwd = M.get_cwd() ---@type string? 81 | local function check_in_git_repo() 82 | local cmd = "git rev-parse --is-inside-work-tree --is-inside-git-dir" 83 | run_cmd_async(cmd, { 84 | on_complete = function(success, output) 85 | local is_git_repo = success and #output > 0 and output[1]:find("true") ~= nil 86 | if not is_git_repo and cwd ~= nil then 87 | cwd = nil 88 | check_in_git_repo() 89 | return 90 | end 91 | on_result(is_git_repo) 92 | end, 93 | }) 94 | end 95 | check_in_git_repo() 96 | end 97 | 98 | ---@class cmp_git.GitInfo 99 | ---@field host string? 100 | ---@field owner string? 101 | ---@field repo string? 102 | 103 | ---@param remotes string|string[] 104 | ---@param opts {enableRemoteUrlRewrites: boolean, ssh_aliases: {[string]: string}, on_complete: fun(git_info: cmp_git.GitInfo): nil} 105 | ---@return nil 106 | function M.get_git_info(remotes, opts) 107 | opts = opts or {} 108 | local cwd = M.get_cwd() ---@type string? 109 | 110 | local get_git_info ---@type fun(): nil 111 | 112 | ---@param git_info cmp_git.GitInfo 113 | local function handle_git_info(git_info) 114 | if git_info.host == nil and cwd ~= nil then 115 | cwd = nil 116 | get_git_info() 117 | return 118 | end 119 | if git_info.host ~= nil then 120 | for alias, rhost in pairs(opts.ssh_aliases) do 121 | git_info.host = git_info.host:gsub("^" .. alias:gsub("%-", "%%-"):gsub("%.", "%%.") .. "$", rhost, 1) 122 | end 123 | end 124 | 125 | opts.on_complete(git_info) 126 | end 127 | 128 | get_git_info = function() 129 | if type(remotes) == "string" then 130 | remotes = { remotes } 131 | end 132 | 133 | ---@type string?, string?, string? 134 | local host, owner, repo = nil, nil, nil 135 | 136 | if vim.bo.filetype == "octo" then 137 | host = require("octo.config").values.github_hostname or "" 138 | if host == "" then 139 | host = "github.com" 140 | end 141 | local filename = vim.fn.expand("%:p:h") 142 | owner, repo = string.match(filename, "^octo://([^/]+)/([^/]+)") 143 | handle_git_info({ host = host, owner = owner, repo = repo }) 144 | return 145 | end 146 | local remote_index = 1 147 | local function check_remote() 148 | if remote_index > #remotes then 149 | handle_git_info({ host = host, owner = owner, repo = repo }) 150 | return 151 | end 152 | local remote = remotes[remote_index] 153 | local cmd ---@type string 154 | if opts.enableRemoteUrlRewrites then 155 | cmd = "git remote get-url " .. remote 156 | else 157 | cmd = "git config --get remote." .. remote .. ".url" 158 | end 159 | run_cmd_async(cmd, { 160 | on_complete = function(success, output) 161 | remote_index = remote_index + 1 162 | if not success then 163 | check_remote() 164 | return 165 | end 166 | local remote_origin_url = output[1] 167 | if remote_origin_url ~= "" then 168 | local clean_remote_origin_url = remote_origin_url:gsub("%.git", ""):gsub("%s", "") 169 | 170 | host, owner, repo = string.match(clean_remote_origin_url, "^git.*@(.+):(.+)/(.+)$") 171 | 172 | if host == nil then 173 | host, owner, repo = string.match(clean_remote_origin_url, "^https?://(.+)/(.+)/(.+)$") 174 | end 175 | 176 | if host == nil then 177 | host, owner, repo = 178 | string.match(clean_remote_origin_url, "^ssh://git@([^:]+):*.*/(.+)/(.+)$") 179 | end 180 | 181 | if host == nil then 182 | host, owner, repo = string.match(clean_remote_origin_url, "^([^:]+):(.+)/(.+)$") 183 | end 184 | 185 | if host ~= nil and owner ~= nil and repo ~= nil then 186 | handle_git_info({ host = host, owner = owner, repo = repo }) 187 | return 188 | end 189 | end 190 | end, 191 | cwd = cwd, 192 | }) 193 | end 194 | check_remote() 195 | 196 | return { host = host, owner = owner, repo = repo } 197 | end 198 | get_git_info() 199 | end 200 | 201 | function M.get_cwd() 202 | if vim.fn.getreg("%") ~= "" and vim.bo.filetype ~= "octo" then 203 | return vim.fn.expand("%:p:h") 204 | end 205 | return vim.fn.getcwd() 206 | end 207 | 208 | ---@param exec string 209 | ---@param args string[] 210 | ---@param env table? 211 | ---@param callback fun(result: string, success: boolean): nil 212 | function M.build_simple_job(exec, args, env, callback) 213 | -- TODO: Find a nicer way, that we can keep chaining jobs at call side 214 | if vim.fn.executable(exec) ~= 1 or not args then 215 | log.fmt_debug("Can't work with %s for this call", exec) 216 | return nil 217 | end 218 | 219 | local job_env = nil 220 | if env ~= nil then 221 | -- NOTE: setting env causes it to not inherit it from the parent environment 222 | vim.tbl_extend("force", env, { 223 | path = vim.fn.getenv("PATH"), 224 | }) 225 | end 226 | 227 | ---@diagnostic disable-next-line: missing-fields 228 | return Job:new({ 229 | command = exec, 230 | args = args, 231 | env = job_env, 232 | cwd = M.get_cwd(), 233 | on_exit = vim.schedule_wrap(function(job, code) ---@param job Job 234 | if code ~= 0 then 235 | log.fmt_debug("%s returned with exit code %d", exec, code) 236 | else 237 | log.fmt_debug("%s returned with a result", exec) 238 | end 239 | local result_str = table.concat(job:result(), "") 240 | callback(result_str, code == 0) 241 | end), 242 | }) 243 | end 244 | 245 | ---@generic TItem 246 | ---@param exec string 247 | ---@param args string[] 248 | ---@param env table? 249 | ---@param callback fun(list: cmp_git.CompletionList) 250 | ---@param handle_item fun(item: TItem): cmp_git.CompletionItem 251 | ---@param handle_parsed? fun(parsed: any): TItem[] 252 | ---@return Job? 253 | function M.build_job(exec, args, env, callback, handle_item, handle_parsed) 254 | return M.build_simple_job(exec, args, env, function(result, success) 255 | if not success then 256 | return 257 | end 258 | local items = M.handle_response(result, handle_item, handle_parsed) 259 | 260 | callback({ items = items, isIncomplete = false }) 261 | end) 262 | end 263 | 264 | ---Start the second job if the first on fails, handle cases if the first or second job is nil. 265 | ---The last job debug prints on failure 266 | ---@param first Job? 267 | ---@param second Job? 268 | ---@return Job? 269 | function M.chain_fallback(first, second) 270 | if first and second then 271 | first:and_then_on_failure(second) 272 | second:after_failure(function(_, code, _) 273 | log.fmt_debug("%s failed with exit code %d, couldn't retrieve any completion info", second.command, code) 274 | end) 275 | 276 | return first 277 | elseif first then 278 | first:after_failure(function(_, code, _) 279 | log.fmt_debug("%s failed with exit code %d, couldn't retrieve any completion info", first.command, code) 280 | end) 281 | return first 282 | elseif second then 283 | second:after_failure(function(_, code, _) 284 | log.fmt_debug("%s failed with exit code %d, couldn't retrieve any completion info", second.command, code) 285 | end) 286 | return second 287 | else 288 | log.debug("Neither %s or %s could be found", first.command, second.command) 289 | return nil 290 | end 291 | end 292 | 293 | ---@generic TItem 294 | ---@param response string 295 | ---@param handle_item fun(item: TItem): cmp_git.CompletionItem 296 | ---@param handle_parsed fun(parsed: any): TItem[] 297 | ---@return cmp_git.CompletionItem[] 298 | function M.handle_response(response, handle_item, handle_parsed) 299 | local items = {} 300 | 301 | local function process_data(ok, parsed) 302 | if not ok then 303 | log.warn("Failed to parse api result") 304 | return 305 | end 306 | 307 | if handle_parsed then 308 | parsed = handle_parsed(parsed) 309 | end 310 | 311 | for _, item in ipairs(parsed) do 312 | table.insert(items, handle_item(item)) 313 | end 314 | end 315 | 316 | if vim.json and vim.json.decode then 317 | local ok, parsed = pcall(vim.json.decode, response) 318 | process_data(ok, parsed) 319 | else 320 | vim.schedule(function() 321 | local ok, parsed = pcall(vim.fn.json_decode, response) 322 | process_data(ok, parsed) 323 | end) 324 | end 325 | 326 | return items 327 | end 328 | 329 | return M 330 | -------------------------------------------------------------------------------- /selene.toml: -------------------------------------------------------------------------------- 1 | std="lua51+vim" 2 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 120 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 4 5 | quote_style = "AutoPreferDouble" 6 | no_call_parentheses = false 7 | -------------------------------------------------------------------------------- /vim.toml: -------------------------------------------------------------------------------- 1 | [vim] 2 | any = true 3 | --------------------------------------------------------------------------------