├── lua ├── github.lua └── github │ ├── utils │ ├── virtual_scroll.lua │ ├── data_helper.lua │ ├── async.lua │ ├── diff_chunk.lua │ └── diff_parser.lua │ ├── actions │ ├── review_actions.lua │ ├── issue_actions.lua │ └── pr_actions.lua │ ├── feature │ ├── form_components.lua │ ├── pr_editor.lua │ └── pr_creator.lua │ ├── ui │ ├── window.lua │ ├── issue_view.lua │ ├── buffer.lua │ ├── input.lua │ ├── notification.lua │ ├── pr_view.lua │ ├── list_view.lua │ ├── floating.lua │ ├── diff_view.lua │ ├── search.lua │ └── pr_viewer.lua │ ├── commands.lua │ ├── api │ ├── cache.lua │ └── client.lua │ ├── config.lua │ ├── filters │ └── filter_manager.lua │ ├── keymaps.lua │ └── init.lua ├── .gitignore ├── screenshots ├── gh_pr_all.jpg ├── gh_pr_view.jpg └── gh_pr_comments.jpg ├── plugin └── github.vim ├── example_config.lua ├── README.md └── QUICKSTART.md /lua/github.lua: -------------------------------------------------------------------------------- 1 | return require('github.init') 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | *~ 4 | .DS_Store 5 | *.log 6 | .vscode/ 7 | .idea/ 8 | *.luac 9 | -------------------------------------------------------------------------------- /screenshots/gh_pr_all.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krshrimali/gh.nvim/main/screenshots/gh_pr_all.jpg -------------------------------------------------------------------------------- /screenshots/gh_pr_view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krshrimali/gh.nvim/main/screenshots/gh_pr_view.jpg -------------------------------------------------------------------------------- /screenshots/gh_pr_comments.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krshrimali/gh.nvim/main/screenshots/gh_pr_comments.jpg -------------------------------------------------------------------------------- /plugin/github.vim: -------------------------------------------------------------------------------- 1 | if exists('g:loaded_github') 2 | finish 3 | endif 4 | let g:loaded_github = 1 5 | 6 | " Auto-load on first use 7 | lua require('github').setup() 8 | -------------------------------------------------------------------------------- /lua/github/utils/virtual_scroll.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.get_visible_range(total_items, window_height, scroll_offset) 4 | local start = math.max(1, scroll_offset) 5 | local end_idx = math.min(total_items, scroll_offset + window_height - 1) 6 | return start, end_idx 7 | end 8 | 9 | function M.scroll_to_item_idx(item_idx, total_items, window_height) 10 | if item_idx < 1 then 11 | return 1 12 | end 13 | if item_idx > total_items then 14 | return math.max(1, total_items - window_height + 1) 15 | end 16 | 17 | local scroll_offset = item_idx - math.floor(window_height / 2) 18 | return math.max(1, math.min(scroll_offset, total_items - window_height + 1)) 19 | end 20 | 21 | function M.calculate_scroll_position(current_line, total_lines, window_height) 22 | local scroll_offset = current_line - math.floor(window_height / 2) 23 | return math.max(0, math.min(scroll_offset, total_lines - window_height)) 24 | end 25 | 26 | return M 27 | -------------------------------------------------------------------------------- /example_config.lua: -------------------------------------------------------------------------------- 1 | -- Example configuration for nvim-github plugin 2 | -- Add this to your Neovim config (e.g., ~/.config/nvim/init.lua) 3 | 4 | require('github').setup({ 5 | -- Repository (nil = auto-detect from git) 6 | repo = nil, -- or 'owner/repo' 7 | 8 | -- Cache settings 9 | cache = { 10 | enabled = true, 11 | ttl = { 12 | list = 300, -- 5 minutes 13 | detail = 60, -- 1 minute 14 | diff = 300, -- 5 minutes 15 | }, 16 | }, 17 | 18 | -- UI settings 19 | ui = { 20 | list_height = 20, 21 | detail_split = 'vertical', -- 'vertical', 'horizontal', or 'replace' 22 | diff_split = 'vertical', 23 | virtual_scroll = true, 24 | }, 25 | 26 | -- Performance settings 27 | performance = { 28 | large_file_threshold = 1000, -- Files with more lines will be chunked 29 | chunk_size = 500, -- Lines per chunk 30 | items_per_page = 50, 31 | }, 32 | 33 | -- Custom keymaps (optional) 34 | keymaps = { 35 | open = 'gh', 36 | issues = 'ghi', 37 | prs = 'ghp', 38 | assigned = 'gha', 39 | }, 40 | 41 | -- Default filters 42 | default_filters = { 43 | issues = { 44 | state = 'open', 45 | assignee = nil, -- 'me' to filter assigned to you 46 | }, 47 | prs = { 48 | state = 'open', 49 | assignee = nil, 50 | reviewer = nil, -- 'me' to filter PRs you're reviewing 51 | }, 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /lua/github/utils/data_helper.lua: -------------------------------------------------------------------------------- 1 | -- Helper utilities for safely accessing GitHub API data 2 | -- GitHub API sometimes returns userdata instead of tables, so we need safe accessors 3 | 4 | local M = {} 5 | 6 | -- Helper function to safely get login from a user object (handles userdata) 7 | function M.safe_get_login(user_obj, default) 8 | default = default or 'unknown' 9 | 10 | if not user_obj then 11 | return default 12 | end 13 | 14 | -- Try to access login field with pcall 15 | local ok, login = pcall(function() 16 | return user_obj.login 17 | end) 18 | 19 | if ok and login and type(login) == 'string' and login ~= '' then 20 | return login 21 | end 22 | 23 | -- If accessing failed or login is not a string, return default 24 | return default 25 | end 26 | 27 | -- Helper function to safely get a field from an object (handles userdata) 28 | function M.safe_get_field(obj, field, default) 29 | if not obj then 30 | return default 31 | end 32 | 33 | -- Try to access field with pcall 34 | local ok, value = pcall(function() 35 | return obj[field] 36 | end) 37 | 38 | if ok and value then 39 | return value 40 | end 41 | 42 | return default 43 | end 44 | 45 | -- Helper function to safely get label name from a label object 46 | function M.safe_get_label_name(label_obj, default) 47 | default = default or 'unknown' 48 | 49 | if not label_obj then 50 | return default 51 | end 52 | 53 | -- Try to access name field with pcall 54 | local ok, name = pcall(function() 55 | return label_obj.name 56 | end) 57 | 58 | if ok and name and type(name) == 'string' and name ~= '' then 59 | return name 60 | end 61 | 62 | return default 63 | end 64 | 65 | return M 66 | -------------------------------------------------------------------------------- /lua/github/actions/review_actions.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local client = require('github.api.client') 3 | local config = require('github.config') 4 | local cache = require('github.api.cache') 5 | 6 | function M.approve(repo, number, body, callback) 7 | callback = callback or function(success, data, error) 8 | if success then 9 | vim.notify('PR approved', vim.log.levels.INFO) 10 | cache.invalidate('pr:' .. repo .. ':' .. number) 11 | cache.invalidate('prs:' .. repo) 12 | else 13 | vim.notify('Failed to approve PR: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 14 | end 15 | end 16 | 17 | client.review_pr(repo, number, 'approve', body, callback) 18 | end 19 | 20 | function M.request_changes(repo, number, body, callback) 21 | callback = callback or function(success, data, error) 22 | if success then 23 | vim.notify('Changes requested', vim.log.levels.INFO) 24 | cache.invalidate('pr:' .. repo .. ':' .. number) 25 | cache.invalidate('prs:' .. repo) 26 | else 27 | vim.notify('Failed to request changes: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 28 | end 29 | end 30 | 31 | client.review_pr(repo, number, 'request-changes', body, callback) 32 | end 33 | 34 | function M.comment(repo, number, body, callback) 35 | callback = callback or function(success, data, error) 36 | if success then 37 | vim.notify('Review comment added', vim.log.levels.INFO) 38 | cache.invalidate('pr:' .. repo .. ':' .. number) 39 | cache.invalidate('prs:' .. repo) 40 | else 41 | vim.notify('Failed to add review comment: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 42 | end 43 | end 44 | 45 | client.review_pr(repo, number, 'comment', body, callback) 46 | end 47 | 48 | return M 49 | -------------------------------------------------------------------------------- /lua/github/feature/form_components.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- Multi-select with vim.ui.select (supports Telescope if available) 4 | function M.multi_select(title, items, callback) 5 | -- items = { {display='label1', value='val1'}, ... } 6 | local selected = {} 7 | 8 | local function select_next() 9 | local remaining = vim.tbl_filter(function(item) 10 | return not vim.tbl_contains(selected, item.value) 11 | end, items) 12 | 13 | if #remaining == 0 then 14 | callback(selected) 15 | return 16 | end 17 | 18 | local display_items = vim.tbl_map(function(item) 19 | return item.display 20 | end, remaining) 21 | 22 | -- Add "Done" option 23 | table.insert(display_items, 1, '[Done - ' .. #selected .. ' selected]') 24 | 25 | vim.ui.select(display_items, { 26 | prompt = title .. ' (select or choose Done):', 27 | }, function(choice, idx) 28 | if not choice or idx == 1 then 29 | callback(selected) 30 | else 31 | table.insert(selected, remaining[idx - 1].value) 32 | vim.notify('Selected: ' .. choice .. ' (total: ' .. #selected .. ')', vim.log.levels.INFO) 33 | select_next() -- Recursively select more 34 | end 35 | end) 36 | end 37 | 38 | select_next() 39 | end 40 | 41 | -- Single-select with filtering (for branches, base branch) 42 | function M.single_select(title, items, default, callback) 43 | -- items = string[] or {display, value}[] 44 | local normalized = vim.tbl_map(function(item) 45 | if type(item) == 'string' then 46 | return { display = item, value = item } 47 | else 48 | return item 49 | end 50 | end, items) 51 | 52 | local display_items = vim.tbl_map(function(item) return item.display end, normalized) 53 | 54 | vim.ui.select(display_items, { 55 | prompt = title .. ':', 56 | default = default, 57 | }, function(choice, idx) 58 | if choice and idx then 59 | callback(normalized[idx].value) 60 | else 61 | callback(nil) -- Cancelled 62 | end 63 | end) 64 | end 65 | 66 | return M 67 | -------------------------------------------------------------------------------- /lua/github/ui/window.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | function M.open_list_window() 5 | local bufnr = vim.api.nvim_get_current_buf() 6 | local winid = vim.api.nvim_get_current_win() 7 | 8 | -- Check if we're already in a GitHub buffer 9 | local buffer = require('github.ui.buffer') 10 | if buffer.is_github_buffer(bufnr) then 11 | return winid 12 | end 13 | 14 | -- Open in current window or split 15 | if config.config.ui.detail_split == 'replace' then 16 | return winid 17 | else 18 | -- Split window 19 | local height = config.config.ui.list_height 20 | vim.cmd('split') 21 | local new_winid = vim.api.nvim_get_current_win() 22 | vim.api.nvim_win_set_height(new_winid, height) 23 | return new_winid 24 | end 25 | end 26 | 27 | function M.open_detail_window(side) 28 | side = side or config.config.ui.detail_split 29 | 30 | if side == 'replace' then 31 | return vim.api.nvim_get_current_win() 32 | elseif side == 'vertical' then 33 | vim.cmd('vsplit') 34 | else 35 | vim.cmd('split') 36 | end 37 | 38 | return vim.api.nvim_get_current_win() 39 | end 40 | 41 | function M.open_diff_window() 42 | local split = config.config.ui.diff_split 43 | 44 | -- Simplified to single unified diff window 45 | if split == 'replace' then 46 | return vim.api.nvim_get_current_win() 47 | elseif split == 'vertical' then 48 | vim.cmd('vsplit') 49 | else 50 | vim.cmd('split') 51 | end 52 | 53 | return vim.api.nvim_get_current_win() 54 | end 55 | 56 | function M.close_all_windows() 57 | local buffers = vim.api.nvim_list_bufs() 58 | local buffer = require('github.ui.buffer') 59 | 60 | for _, bufnr in ipairs(buffers) do 61 | if buffer.is_github_buffer(bufnr) then 62 | local wins = vim.fn.win_findbuf(bufnr) 63 | for _, winid in ipairs(wins) do 64 | if vim.api.nvim_win_is_valid(winid) then 65 | vim.api.nvim_win_close(winid, false) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | 72 | function M.get_window_for_buffer(bufnr) 73 | local wins = vim.fn.win_findbuf(bufnr) 74 | if #wins > 0 then 75 | return wins[1] 76 | end 77 | return nil 78 | end 79 | 80 | return M 81 | -------------------------------------------------------------------------------- /lua/github/commands.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | function M.setup_commands() 5 | vim.api.nvim_create_user_command('Github', function() 6 | require('github').open() 7 | end, { desc = 'Open GitHub plugin' }) 8 | 9 | vim.api.nvim_create_user_command('GithubIssues', function() 10 | require('github').open_issues() 11 | end, { desc = 'Open GitHub Issues list' }) 12 | 13 | vim.api.nvim_create_user_command('GithubPRs', function() 14 | require('github').open_prs() 15 | end, { desc = 'Open GitHub PRs list' }) 16 | 17 | vim.api.nvim_create_user_command('GithubAssigned', function() 18 | require('github').open_assigned() 19 | end, { desc = 'Open assigned Issues/PRs' }) 20 | 21 | vim.api.nvim_create_user_command('GithubRefresh', function() 22 | require('github').refresh() 23 | end, { desc = 'Refresh current GitHub view' }) 24 | 25 | vim.api.nvim_create_user_command('GithubIssuesClosed', function() 26 | require('github').open_issues_closed() 27 | end, { desc = 'Open closed GitHub Issues' }) 28 | 29 | vim.api.nvim_create_user_command('GithubPRsClosed', function() 30 | require('github').open_prs_closed() 31 | end, { desc = 'Open closed GitHub PRs' }) 32 | 33 | vim.api.nvim_create_user_command('GithubIssuesAll', function() 34 | require('github').open_issues_all() 35 | end, { desc = 'Open all GitHub Issues' }) 36 | 37 | vim.api.nvim_create_user_command('GithubPRsAll', function() 38 | require('github').open_prs_all() 39 | end, { desc = 'Open all GitHub PRs' }) 40 | 41 | vim.api.nvim_create_user_command('GithubIssueCreate', function(opts) 42 | local title = opts.args 43 | if not title or title == '' then 44 | title = vim.fn.input('Issue title: ') 45 | end 46 | if title and title ~= '' then 47 | local body = vim.fn.input('Issue body (optional): ') 48 | require('github').create_issue(title, body) 49 | end 50 | end, { nargs = '?', desc = 'Create a new GitHub issue' }) 51 | 52 | vim.api.nvim_create_user_command('GithubPRCreate', function(opts) 53 | require('github').create_pr_interactive() 54 | end, { desc = 'Create a new GitHub PR (interactive form)' }) 55 | 56 | -- Search commands 57 | vim.api.nvim_create_user_command('GithubSearchPRs', function() 58 | require('github').search_prs() 59 | end, { desc = 'Search GitHub PRs with fuzzy finder' }) 60 | 61 | vim.api.nvim_create_user_command('GithubSearchIssues', function() 62 | require('github').search_issues() 63 | end, { desc = 'Search GitHub Issues with fuzzy finder' }) 64 | end 65 | 66 | return M 67 | -------------------------------------------------------------------------------- /lua/github/utils/async.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | local job_queue = {} 5 | local running_jobs = {} 6 | local job_id_counter = 0 7 | 8 | function M.queue_job(cmd, callback, priority) 9 | priority = priority or 0 10 | table.insert(job_queue, { cmd = cmd, callback = callback, priority = priority }) 11 | table.sort(job_queue, function(a, b) return a.priority > b.priority end) 12 | M.process_queue() 13 | end 14 | 15 | function M.process_queue() 16 | if #running_jobs >= config.config.performance.max_concurrent_jobs then 17 | return 18 | end 19 | 20 | if #job_queue == 0 then 21 | return 22 | end 23 | 24 | local job = table.remove(job_queue, 1) 25 | M.run_job(job.cmd, job.callback) 26 | end 27 | 28 | function M.run_job(cmd, callback) 29 | if not cmd or type(cmd) ~= 'table' then 30 | if callback then 31 | callback(false, nil, 'Invalid command: expected table') 32 | end 33 | return 34 | end 35 | 36 | job_id_counter = job_id_counter + 1 37 | local job_id = job_id_counter 38 | 39 | running_jobs[job_id] = { 40 | cmd = cmd, 41 | callback = callback, 42 | } 43 | 44 | -- Build command array: [gh_path, ...cmd_args] 45 | -- Ensure all elements are strings 46 | local gh_path = config.config.gh_path or 'gh' 47 | local cmd_array = { gh_path } 48 | 49 | for _, arg in ipairs(cmd) do 50 | if arg ~= nil then 51 | table.insert(cmd_array, tostring(arg)) 52 | end 53 | end 54 | 55 | -- Add custom gh args if configured 56 | if config.config.advanced.gh_args and #config.config.advanced.gh_args > 0 then 57 | for _, arg in ipairs(config.config.advanced.gh_args) do 58 | if arg ~= nil then 59 | table.insert(cmd_array, tostring(arg)) 60 | end 61 | end 62 | end 63 | 64 | -- Use vim.system() for Neovim 0.10+ (simpler API) 65 | local handle = vim.system(cmd_array, { 66 | text = true, 67 | }, function(result) 68 | running_jobs[job_id] = nil 69 | 70 | if result.code == 0 then 71 | if callback then 72 | callback(true, result.stdout, nil) 73 | end 74 | else 75 | if callback then 76 | callback(false, nil, result.stderr or 'Command failed with code ' .. result.code) 77 | end 78 | end 79 | 80 | -- Process next job in queue 81 | M.process_queue() 82 | end) 83 | 84 | if not handle then 85 | running_jobs[job_id] = nil 86 | if callback then 87 | callback(false, nil, 'Failed to start job') 88 | end 89 | end 90 | end 91 | 92 | function M.cancel_job(job_id) 93 | -- Note: Neovim doesn't support job cancellation directly 94 | -- This is a placeholder for future implementation 95 | running_jobs[job_id] = nil 96 | end 97 | 98 | function M.get_running_jobs() 99 | return vim.tbl_keys(running_jobs) 100 | end 101 | 102 | return M 103 | -------------------------------------------------------------------------------- /lua/github/api/cache.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | local cache_dir = config.config.cache.directory 5 | 6 | function M.get_key_path(key) 7 | return cache_dir .. '/' .. key:gsub('[:/]', '_') .. '.json' 8 | end 9 | 10 | function M.get(key) 11 | if not config.config.cache.enabled then 12 | return nil 13 | end 14 | 15 | local cache_file = M.get_key_path(key) 16 | local ok, data = pcall(function() 17 | local file = io.open(cache_file, 'r') 18 | if not file then 19 | return nil 20 | end 21 | 22 | local content = file:read('*a') 23 | file:close() 24 | 25 | local cache_data = vim.json.decode(content) 26 | 27 | -- Check TTL 28 | local age = os.time() - cache_data.timestamp 29 | if age > cache_data.ttl then 30 | return nil 31 | end 32 | 33 | return cache_data.data 34 | end) 35 | 36 | if not ok then 37 | -- Cache corrupted, delete it 38 | pcall(function() 39 | os.remove(cache_file) 40 | end) 41 | return nil 42 | end 43 | 44 | return data 45 | end 46 | 47 | function M.set(key, data, ttl) 48 | if not config.config.cache.enabled then 49 | return 50 | end 51 | 52 | ttl = ttl or config.config.cache.ttl.list 53 | 54 | local cache_file = M.get_key_path(key) 55 | local cache_data = { 56 | data = data, 57 | timestamp = os.time(), 58 | ttl = ttl, 59 | } 60 | 61 | local ok, err = pcall(function() 62 | -- Write to temp file first, then rename (atomic) 63 | local temp_file = cache_file .. '.tmp' 64 | local file = io.open(temp_file, 'w') 65 | if not file then 66 | error('Failed to open cache file: ' .. temp_file) 67 | end 68 | 69 | file:write(vim.json.encode(cache_data)) 70 | file:close() 71 | 72 | os.rename(temp_file, cache_file) 73 | end) 74 | 75 | if not ok then 76 | if config.config.advanced.debug then 77 | vim.notify('Cache write error: ' .. tostring(err), vim.log.levels.WARN) 78 | end 79 | end 80 | end 81 | 82 | function M.invalidate(pattern) 83 | if not config.config.cache.enabled then 84 | return 85 | end 86 | 87 | -- Schedule to avoid fast event context issues 88 | vim.schedule(function() 89 | local files = vim.fn.glob(cache_dir .. '/*.json', false, true) 90 | for _, file in ipairs(files) do 91 | local filename = vim.fn.fnamemodify(file, ':t') 92 | if string.match(filename, pattern) then 93 | pcall(function() 94 | os.remove(file) 95 | end) 96 | end 97 | end 98 | end) 99 | end 100 | 101 | function M.clear() 102 | if not config.config.cache.enabled then 103 | return 104 | end 105 | 106 | -- Schedule to avoid fast event context issues 107 | vim.schedule(function() 108 | local files = vim.fn.glob(cache_dir .. '/*.json', false, true) 109 | for _, file in ipairs(files) do 110 | pcall(function() 111 | os.remove(file) 112 | end) 113 | end 114 | end) 115 | end 116 | 117 | return M 118 | -------------------------------------------------------------------------------- /lua/github/actions/issue_actions.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local client = require('github.api.client') 3 | local config = require('github.config') 4 | local cache = require('github.api.cache') 5 | 6 | function M.create(repo, title, body, callback) 7 | callback = callback or function(success, data, error) 8 | if success then 9 | vim.notify('Issue created: #' .. data.number, vim.log.levels.INFO) 10 | else 11 | vim.notify('Failed to create issue: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 12 | end 13 | end 14 | 15 | client.create_issue(repo, title, body, callback) 16 | end 17 | 18 | function M.add_comment(repo, number, body, callback) 19 | callback = callback or function(success, data, error) 20 | if success then 21 | vim.notify('Comment added', vim.log.levels.INFO) 22 | -- Invalidate cache 23 | cache.invalidate('issue:' .. repo .. ':' .. number) 24 | cache.invalidate('issues:' .. repo) 25 | else 26 | vim.notify('Failed to add comment: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 27 | end 28 | end 29 | 30 | client.add_issue_comment(repo, number, body, callback) 31 | end 32 | 33 | function M.assign(repo, number, assignees, callback) 34 | -- Note: This would require gh issue edit command 35 | -- Simplified implementation 36 | callback = callback or function(success, data, error) 37 | if success then 38 | vim.notify('Issue assigned', vim.log.levels.INFO) 39 | cache.invalidate('issue:' .. repo .. ':' .. number) 40 | cache.invalidate('issues:' .. repo) 41 | else 42 | vim.notify('Failed to assign issue: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 43 | end 44 | end 45 | 46 | local assignees_str = table.concat(assignees, ',') 47 | local cmd = { 'issue', 'edit', tostring(number), '--repo', repo, '--add-assignee', assignees_str } 48 | client.run_command(cmd, callback) 49 | end 50 | 51 | function M.add_labels(repo, number, labels, callback) 52 | callback = callback or function(success, data, error) 53 | if success then 54 | vim.notify('Labels added', vim.log.levels.INFO) 55 | cache.invalidate('issue:' .. repo .. ':' .. number) 56 | cache.invalidate('issues:' .. repo) 57 | else 58 | vim.notify('Failed to add labels: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 59 | end 60 | end 61 | 62 | local labels_str = table.concat(labels, ',') 63 | local cmd = { 'issue', 'edit', tostring(number), '--repo', repo, '--add-label', labels_str } 64 | client.run_command(cmd, callback) 65 | end 66 | 67 | function M.close(repo, number, callback) 68 | callback = callback or function(success, data, error) 69 | if success then 70 | vim.notify('Issue closed', vim.log.levels.INFO) 71 | cache.invalidate('issue:' .. repo .. ':' .. number) 72 | cache.invalidate('issues:' .. repo) 73 | else 74 | vim.notify('Failed to close issue: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 75 | end 76 | end 77 | 78 | local cmd = { 'issue', 'close', tostring(number), '--repo', repo } 79 | client.run_command(cmd, callback) 80 | end 81 | 82 | return M 83 | -------------------------------------------------------------------------------- /lua/github/utils/diff_chunk.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | function M.chunk_file(file_data, threshold) 5 | threshold = threshold or config.config.performance.large_file_threshold 6 | local chunk_size = config.config.performance.chunk_size 7 | 8 | if not file_data.hunks or #file_data.hunks == 0 then 9 | return { { type = 'full', file = file_data } } 10 | end 11 | 12 | -- Calculate total lines 13 | local total_lines = 0 14 | for _, hunk in ipairs(file_data.hunks) do 15 | total_lines = total_lines + math.max(hunk.old_count, hunk.new_count) 16 | end 17 | 18 | if total_lines <= threshold then 19 | return { { type = 'full', file = file_data, is_expanded = true } } 20 | end 21 | 22 | -- Split into chunks 23 | local chunks = {} 24 | local current_chunk = { 25 | type = 'chunk', 26 | file = file_data.file, 27 | hunks = {}, 28 | start_hunk = 1, 29 | end_hunk = 1, 30 | is_expanded = false, 31 | } 32 | 33 | local lines_in_chunk = 0 34 | 35 | for i, hunk in ipairs(file_data.hunks) do 36 | local hunk_lines = math.max(hunk.old_count, hunk.new_count) 37 | 38 | if lines_in_chunk + hunk_lines > chunk_size and #current_chunk.hunks > 0 then 39 | current_chunk.end_hunk = i - 1 40 | table.insert(chunks, current_chunk) 41 | current_chunk = { 42 | type = 'chunk', 43 | file = file_data.file, 44 | hunks = {}, 45 | start_hunk = i, 46 | end_hunk = i, 47 | is_expanded = false, 48 | } 49 | lines_in_chunk = 0 50 | end 51 | 52 | table.insert(current_chunk.hunks, hunk) 53 | current_chunk.end_hunk = i 54 | lines_in_chunk = lines_in_chunk + hunk_lines 55 | end 56 | 57 | if #current_chunk.hunks > 0 then 58 | table.insert(chunks, current_chunk) 59 | end 60 | 61 | -- First chunk is expanded by default 62 | if #chunks > 0 then 63 | chunks[1].is_expanded = true 64 | end 65 | 66 | return chunks 67 | end 68 | 69 | function M.expand_chunk(chunks, chunk_idx) 70 | if chunk_idx < 1 or chunk_idx > #chunks then 71 | return false 72 | end 73 | 74 | chunks[chunk_idx].is_expanded = true 75 | return true 76 | end 77 | 78 | function M.collapse_chunk(chunks, chunk_idx) 79 | if chunk_idx < 1 or chunk_idx > #chunks then 80 | return false 81 | end 82 | 83 | -- Don't collapse if it's the only chunk 84 | if #chunks == 1 then 85 | return false 86 | end 87 | 88 | chunks[chunk_idx].is_expanded = false 89 | return true 90 | end 91 | 92 | function M.get_chunk_info(chunks, chunk_idx) 93 | if chunk_idx < 1 or chunk_idx > #chunks then 94 | return nil 95 | end 96 | 97 | local chunk = chunks[chunk_idx] 98 | local total_lines = 0 99 | for _, hunk in ipairs(chunk.hunks) do 100 | total_lines = total_lines + math.max(hunk.old_count, hunk.new_count) 101 | end 102 | 103 | return { 104 | chunk_idx = chunk_idx, 105 | total_chunks = #chunks, 106 | lines = total_lines, 107 | is_expanded = chunk.is_expanded, 108 | start_hunk = chunk.start_hunk, 109 | end_hunk = chunk.end_hunk, 110 | } 111 | end 112 | 113 | return M 114 | -------------------------------------------------------------------------------- /lua/github/ui/issue_view.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local buffer = require('github.ui.buffer') 3 | local data_helper = require('github.utils.data_helper') 4 | 5 | function M.render_issue(bufnr, issue) 6 | vim.b[bufnr].github_issue = issue 7 | 8 | local lines = {} 9 | local highlights = {} 10 | 11 | -- Header 12 | local title = issue.title or 'No title' 13 | -- Remove newlines from title (take first line only) 14 | title = vim.split(title, '\n')[1] or title 15 | table.insert(lines, string.format('#%d: %s', issue.number, title)) 16 | table.insert(highlights, { 0, 0, -1, 'Title' }) 17 | 18 | -- State and labels 19 | local state_line = '[' .. (issue.state == 'open' and 'Open' or 'Closed') .. ']' 20 | if issue.labels and #issue.labels > 0 then 21 | local label_names = {} 22 | for _, label in ipairs(issue.labels) do 23 | local label_name = data_helper.safe_get_label_name(label) 24 | table.insert(label_names, label_name) 25 | end 26 | state_line = state_line .. ' ' .. table.concat(label_names, ', ') 27 | end 28 | table.insert(lines, state_line) 29 | 30 | -- Metadata 31 | table.insert(lines, '') 32 | local author = data_helper.safe_get_login(issue.author) 33 | table.insert(lines, string.format('Author: @%s', author)) 34 | 35 | if issue.assignees and #issue.assignees > 0 then 36 | local assignee_names = {} 37 | for _, assignee in ipairs(issue.assignees) do 38 | local assignee_login = data_helper.safe_get_login(assignee) 39 | table.insert(assignee_names, '@' .. assignee_login) 40 | end 41 | table.insert(lines, 'Assignees: ' .. table.concat(assignee_names, ', ')) 42 | end 43 | 44 | if issue.createdAt then 45 | table.insert(lines, 'Created: ' .. M.format_date(issue.createdAt)) 46 | end 47 | 48 | -- Body 49 | table.insert(lines, '') 50 | table.insert(lines, '─────────────────────────────────────') 51 | table.insert(lines, '') 52 | 53 | if issue.body and issue.body ~= '' then 54 | local body_lines = vim.split(issue.body, '\n') 55 | for _, line in ipairs(body_lines) do 56 | table.insert(lines, line) 57 | end 58 | else 59 | table.insert(lines, 'No description provided.') 60 | end 61 | 62 | -- Comments 63 | if issue.comments and #issue.comments > 0 then 64 | table.insert(lines, '') 65 | table.insert(lines, '─────────────────────────────────────') 66 | table.insert(lines, '') 67 | table.insert(lines, string.format('Comments (%d):', #issue.comments)) 68 | table.insert(lines, '') 69 | 70 | for _, comment in ipairs(issue.comments) do 71 | local comment_author = data_helper.safe_get_login(comment.author) 72 | table.insert(lines, string.format('@%s:', comment_author)) 73 | if comment.body then 74 | local comment_lines = vim.split(comment.body, '\n') 75 | for _, line in ipairs(comment_lines) do 76 | table.insert(lines, ' ' .. line) 77 | end 78 | end 79 | if comment.createdAt then 80 | table.insert(lines, ' ── ' .. M.format_date(comment.createdAt)) 81 | end 82 | table.insert(lines, '') 83 | end 84 | end 85 | 86 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 87 | 88 | -- Apply highlights 89 | for _, hl in ipairs(highlights) do 90 | vim.api.nvim_buf_add_highlight(bufnr, 0, hl[4], hl[1], hl[2], hl[3]) 91 | end 92 | end 93 | 94 | function M.format_date(date_str) 95 | if not date_str then 96 | return '' 97 | end 98 | 99 | -- Parse ISO 8601 date 100 | local year, month, day = string.match(date_str, '(%d+)-(%d+)-(%d+)') 101 | if year and month and day then 102 | return string.format('%s-%s-%s', year, month, day) 103 | end 104 | 105 | return date_str 106 | end 107 | 108 | return M 109 | -------------------------------------------------------------------------------- /lua/github/ui/buffer.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | local BUFFER_TYPES = { 5 | LIST = 'github_list', 6 | ISSUE = 'github_issue', 7 | PR = 'github_pr', 8 | DIFF = 'github_diff', 9 | COMMENT = 'github_comment', 10 | PR_INFO = 'github_pr_info', 11 | } 12 | 13 | function M.create_buffer(type, data) 14 | -- For LIST buffers, distinguish between issues and PRs 15 | local subtype = data and data.type or nil 16 | 17 | -- Check if a buffer with this type already exists 18 | local existing_bufnr = M.find_buffer_by_type(type, subtype) 19 | 20 | if existing_bufnr then 21 | -- Clear existing buffer content and update data 22 | vim.bo[existing_bufnr].modifiable = true 23 | vim.api.nvim_buf_set_lines(existing_bufnr, 0, -1, false, {}) 24 | 25 | if data then 26 | vim.b[existing_bufnr].github_data = data 27 | end 28 | 29 | return existing_bufnr 30 | end 31 | 32 | -- Create new buffer 33 | local bufnr = vim.api.nvim_create_buf(false, true) 34 | M.setup_buffer(bufnr, type) 35 | 36 | if data then 37 | vim.b[bufnr].github_data = data 38 | end 39 | 40 | return bufnr 41 | end 42 | 43 | function M.setup_buffer(bufnr, type) 44 | -- Generate unique buffer name by appending buffer number 45 | local buf_name = type .. '_' .. bufnr 46 | 47 | -- Try to set name, if it fails (name exists), try with timestamp 48 | local ok = pcall(vim.api.nvim_buf_set_name, bufnr, buf_name) 49 | if not ok then 50 | buf_name = type .. '_' .. os.time() 51 | vim.api.nvim_buf_set_name(bufnr, buf_name) 52 | end 53 | 54 | vim.b[bufnr].github_type = type 55 | 56 | -- Buffer options (using modern API) 57 | vim.bo[bufnr].buftype = 'nofile' 58 | vim.bo[bufnr].swapfile = false 59 | vim.bo[bufnr].bufhidden = 'wipe' 60 | vim.bo[bufnr].modifiable = true 61 | 62 | -- Filetype for syntax highlighting 63 | if type == BUFFER_TYPES.DIFF then 64 | vim.bo[bufnr].filetype = 'diff' 65 | elseif type == BUFFER_TYPES.COMMENT then 66 | vim.bo[bufnr].filetype = 'markdown' 67 | end 68 | end 69 | 70 | -- Setup window options (called after buffer is displayed in a window) 71 | function M.setup_window_options(winid) 72 | if not winid or not vim.api.nvim_win_is_valid(winid) then 73 | return 74 | end 75 | 76 | -- Line numbers (window options) 77 | if config.config.ui.number then 78 | vim.wo[winid].number = true 79 | end 80 | if config.config.ui.relativenumber then 81 | vim.wo[winid].relativenumber = true 82 | end 83 | end 84 | 85 | function M.update_buffer(bufnr, data) 86 | if not vim.api.nvim_buf_is_valid(bufnr) then 87 | return false 88 | end 89 | 90 | vim.b[bufnr].github_data = data 91 | return true 92 | end 93 | 94 | function M.get_buffer_type(bufnr) 95 | return vim.b[bufnr].github_type 96 | end 97 | 98 | function M.get_buffer_data(bufnr) 99 | return vim.b[bufnr].github_data 100 | end 101 | 102 | function M.is_github_buffer(bufnr) 103 | bufnr = bufnr or vim.api.nvim_get_current_buf() 104 | local buf_type = M.get_buffer_type(bufnr) 105 | return buf_type ~= nil 106 | end 107 | 108 | -- Find buffer by type (searches all buffers) 109 | function M.find_buffer_by_type(type, subtype) 110 | for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do 111 | -- Check if buffer is valid AND loaded (not wiped) 112 | if vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) then 113 | local buf_type = vim.b[bufnr].github_type 114 | if buf_type == type then 115 | -- For LIST type, also check subtype (issues vs prs) 116 | if type == BUFFER_TYPES.LIST and subtype then 117 | local buf_data = vim.b[bufnr].github_data 118 | if buf_data and buf_data.type == subtype then 119 | return bufnr 120 | end 121 | else 122 | -- For other types, just match the type 123 | return bufnr 124 | end 125 | end 126 | end 127 | end 128 | return nil 129 | end 130 | 131 | M.BUFFER_TYPES = BUFFER_TYPES 132 | 133 | return M 134 | -------------------------------------------------------------------------------- /lua/github/ui/input.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local floating = require('github.ui.floating') 3 | 4 | -- Show a single-line input prompt 5 | function M.show_input(prompt, default_value, on_submit, opts) 6 | opts = opts or {} 7 | default_value = default_value or '' 8 | 9 | local width = opts.width or 60 10 | local placeholder = opts.placeholder or '' 11 | 12 | -- Create floating window 13 | local float = floating.create_input_float({ 14 | width = width, 15 | height = 1, 16 | title = prompt, 17 | }) 18 | 19 | local bufnr = float.bufnr 20 | local winid = float.winid 21 | 22 | -- Set initial content 23 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { default_value }) 24 | vim.bo[bufnr].modifiable = true 25 | vim.bo[bufnr].buftype = 'prompt' 26 | 27 | -- Set cursor to end of line 28 | vim.api.nvim_win_set_cursor(winid, { 1, #default_value }) 29 | 30 | -- Enter insert mode 31 | vim.cmd('startinsert!') 32 | 33 | -- Submit on Enter 34 | vim.keymap.set('i', '', function() 35 | local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 36 | local value = lines[1] or '' 37 | float.close() 38 | if on_submit then 39 | on_submit(value) 40 | end 41 | end, { buffer = bufnr, silent = true }) 42 | 43 | -- Cancel on Esc 44 | vim.keymap.set({ 'n', 'i' }, '', function() 45 | float.close() 46 | if on_submit then 47 | on_submit(nil) -- nil indicates cancelled 48 | end 49 | end, { buffer = bufnr, silent = true }) 50 | 51 | return float 52 | end 53 | 54 | -- Show a multi-line input (for comments, etc.) 55 | function M.show_multiline_input(prompt, default_value, on_submit, opts) 56 | opts = opts or {} 57 | default_value = default_value or '' 58 | 59 | local width = opts.width or math.floor(vim.o.columns * 0.6) 60 | local height = opts.height or 10 61 | local syntax = opts.syntax or 'markdown' 62 | 63 | -- Create floating window 64 | local float = floating.create_centered_float({ 65 | width = width, 66 | height = height, 67 | title = prompt, 68 | filetype = syntax, 69 | }) 70 | 71 | local bufnr = float.bufnr 72 | local winid = float.winid 73 | 74 | -- Set initial content 75 | local lines 76 | if type(default_value) == 'string' then 77 | lines = vim.split(default_value, '\n') 78 | else 79 | lines = default_value 80 | end 81 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 82 | vim.bo[bufnr].modifiable = true 83 | 84 | -- Add instructions at the bottom 85 | local instruction_ns = vim.api.nvim_create_namespace('github_input_instructions') 86 | vim.api.nvim_buf_set_extmark(bufnr, instruction_ns, 0, 0, { 87 | virt_text = { { ' Save | Cancel', 'Comment' } }, 88 | virt_text_pos = 'right_align', 89 | }) 90 | 91 | -- Enter insert mode 92 | vim.cmd('startinsert') 93 | 94 | -- Submit on Ctrl+S 95 | vim.keymap.set({ 'n', 'i' }, '', function() 96 | local content_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 97 | local value = table.concat(content_lines, '\n') 98 | float.close() 99 | if on_submit then 100 | on_submit(value) 101 | end 102 | end, { buffer = bufnr, silent = true }) 103 | 104 | -- Cancel on Ctrl+C or Esc (in normal mode) 105 | vim.keymap.set({ 'n', 'i' }, '', function() 106 | float.close() 107 | if on_submit then 108 | on_submit(nil) 109 | end 110 | end, { buffer = bufnr, silent = true }) 111 | 112 | vim.keymap.set('n', '', function() 113 | float.close() 114 | if on_submit then 115 | on_submit(nil) 116 | end 117 | end, { buffer = bufnr, silent = true }) 118 | 119 | return float 120 | end 121 | 122 | -- Show a confirmation dialog 123 | function M.confirm(message, on_confirm) 124 | local width = math.min(#message + 10, 60) 125 | 126 | local float = floating.create_centered_float({ 127 | width = width, 128 | height = 3, 129 | title = 'Confirm', 130 | }) 131 | 132 | local bufnr = float.bufnr 133 | local winid = float.winid 134 | 135 | -- Set content 136 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 137 | message, 138 | '', 139 | ' Yes No', 140 | }) 141 | vim.bo[bufnr].modifiable = false 142 | 143 | -- Yes/No keymaps 144 | vim.keymap.set('n', 'y', function() 145 | float.close() 146 | if on_confirm then 147 | on_confirm(true) 148 | end 149 | end, { buffer = bufnr, silent = true }) 150 | 151 | vim.keymap.set('n', 'n', function() 152 | float.close() 153 | if on_confirm then 154 | on_confirm(false) 155 | end 156 | end, { buffer = bufnr, silent = true }) 157 | 158 | vim.keymap.set('n', '', function() 159 | float.close() 160 | if on_confirm then 161 | on_confirm(false) 162 | end 163 | end, { buffer = bufnr, silent = true }) 164 | 165 | return float 166 | end 167 | 168 | return M 169 | -------------------------------------------------------------------------------- /lua/github/utils/diff_parser.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.parse_unified_diff(diff_text) 4 | if not diff_text or diff_text == '' then 5 | return {} 6 | end 7 | 8 | local files = {} 9 | local current_file = nil 10 | local current_hunk = nil 11 | local lines = vim.split(diff_text, '\n') 12 | 13 | for i, line in ipairs(lines) do 14 | -- File header: diff --git a/path b/path 15 | if string.match(line, '^diff %-%-git') then 16 | if current_file then 17 | if current_hunk then 18 | table.insert(current_file.hunks, current_hunk) 19 | end 20 | table.insert(files, current_file) 21 | end 22 | current_file = { 23 | file = '', 24 | additions = 0, 25 | deletions = 0, 26 | changes = 0, 27 | hunks = {}, 28 | } 29 | current_hunk = nil 30 | -- File path: --- a/path or +++ b/path 31 | elseif string.match(line, '^%-%-%-') then 32 | local path = string.match(line, '^%-%-%- a/(.+)') 33 | if path then 34 | current_file.file = path 35 | end 36 | elseif string.match(line, '^%+%+%+') then 37 | local path = string.match(line, '^%+%+%+ b/(.+)') 38 | if path and current_file.file == '' then 39 | current_file.file = path 40 | end 41 | -- Hunk header: @@ -start,count +start,count @@ 42 | elseif string.match(line, '^@@') then 43 | if current_hunk then 44 | table.insert(current_file.hunks, current_hunk) 45 | end 46 | local old_start, old_count, new_start, new_count = string.match(line, '@@ %-(%d+),?(%d*) %+(%d+),?(%d*)') 47 | current_hunk = { 48 | old_start = tonumber(old_start), 49 | old_count = tonumber(old_count) or 1, 50 | new_start = tonumber(new_start), 51 | new_count = tonumber(new_count) or 1, 52 | lines = {}, 53 | } 54 | -- Diff lines 55 | elseif current_hunk then 56 | local line_type = 'context' 57 | local content = line 58 | local old_line = nil 59 | local new_line = nil 60 | 61 | -- Track line numbers 62 | if not current_hunk._old_line then 63 | current_hunk._old_line = current_hunk.old_start 64 | current_hunk._new_line = current_hunk.new_start 65 | end 66 | 67 | if string.match(line, '^%-') then 68 | line_type = 'deletion' 69 | content = string.sub(line, 2) 70 | old_line = current_hunk._old_line 71 | current_hunk._old_line = current_hunk._old_line + 1 72 | current_file.deletions = current_file.deletions + 1 73 | elseif string.match(line, '^%+') then 74 | line_type = 'addition' 75 | content = string.sub(line, 2) 76 | new_line = current_hunk._new_line 77 | current_hunk._new_line = current_hunk._new_line + 1 78 | current_file.additions = current_file.additions + 1 79 | else 80 | -- Context line - increment both 81 | old_line = current_hunk._old_line 82 | new_line = current_hunk._new_line 83 | current_hunk._old_line = current_hunk._old_line + 1 84 | current_hunk._new_line = current_hunk._new_line + 1 85 | end 86 | 87 | table.insert(current_hunk.lines, { 88 | type = line_type, 89 | content = content, 90 | old_line = old_line, 91 | new_line = new_line, 92 | }) 93 | end 94 | end 95 | 96 | -- Add last file and hunk 97 | if current_file then 98 | if current_hunk then 99 | table.insert(current_file.hunks, current_hunk) 100 | end 101 | current_file.changes = current_file.additions + current_file.deletions 102 | table.insert(files, current_file) 103 | end 104 | 105 | return files 106 | end 107 | 108 | function M.parse_patch(patch_text) 109 | if not patch_text or patch_text == '' then 110 | return {} 111 | end 112 | 113 | local hunks = {} 114 | local lines = vim.split(patch_text, '\n') 115 | local current_hunk = nil 116 | 117 | for _, line in ipairs(lines) do 118 | if string.match(line, '^@@') then 119 | if current_hunk then 120 | table.insert(hunks, current_hunk) 121 | end 122 | local old_start, old_count, new_start, new_count = string.match(line, '@@ %-(%d+),?(%d*) %+(%d+),?(%d*)') 123 | current_hunk = { 124 | old_start = tonumber(old_start), 125 | old_count = tonumber(old_count) or 1, 126 | new_start = tonumber(new_start), 127 | new_count = tonumber(new_count) or 1, 128 | lines = {}, 129 | } 130 | elseif current_hunk then 131 | local line_type = 'context' 132 | local content = line 133 | 134 | if string.match(line, '^%-') then 135 | line_type = 'deletion' 136 | content = string.sub(line, 2) 137 | elseif string.match(line, '^%+') then 138 | line_type = 'addition' 139 | content = string.sub(line, 2) 140 | end 141 | 142 | table.insert(current_hunk.lines, { 143 | type = line_type, 144 | content = content, 145 | }) 146 | end 147 | end 148 | 149 | if current_hunk then 150 | table.insert(hunks, current_hunk) 151 | end 152 | 153 | return hunks 154 | end 155 | 156 | return M 157 | -------------------------------------------------------------------------------- /lua/github/ui/notification.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local floating = require('github.ui.floating') 3 | 4 | -- Notification queue 5 | local notification_queue = {} 6 | local current_notification = nil 7 | 8 | -- Icons for different notification types 9 | local icons = { 10 | success = '✓', 11 | error = '✗', 12 | warning = '⚠', 13 | info = 'ℹ', 14 | } 15 | 16 | -- Colors/highlights for different types 17 | local highlights = { 18 | success = 'GithubNotifySuccess', 19 | error = 'GithubNotifyError', 20 | warning = 'GithubNotifyWarning', 21 | info = 'GithubNotifyInfo', 22 | } 23 | 24 | -- Show a notification 25 | function M.notify(message, type, opts) 26 | opts = opts or {} 27 | type = type or 'info' 28 | 29 | local notification = { 30 | message = message, 31 | type = type, 32 | timeout = opts.timeout or 3000, 33 | actions = opts.actions or {}, 34 | } 35 | 36 | -- Add to queue 37 | table.insert(notification_queue, notification) 38 | 39 | -- Process queue if no current notification 40 | if not current_notification then 41 | M.process_queue() 42 | end 43 | end 44 | 45 | -- Show notification with action buttons 46 | function M.notify_with_actions(message, type, actions) 47 | M.notify(message, type, { actions = actions, timeout = 0 }) -- No auto-dismiss with actions 48 | end 49 | 50 | -- Process notification queue 51 | function M.process_queue() 52 | if #notification_queue == 0 then 53 | current_notification = nil 54 | return 55 | end 56 | 57 | local notification = table.remove(notification_queue, 1) 58 | current_notification = notification 59 | 60 | M.show_notification(notification) 61 | end 62 | 63 | -- Show a single notification 64 | function M.show_notification(notification) 65 | local message = notification.message 66 | local type = notification.type 67 | local actions = notification.actions 68 | local timeout = notification.timeout 69 | 70 | -- Prepare notification text with icon 71 | local icon = icons[type] or icons.info 72 | local full_message = icon .. ' ' .. message 73 | 74 | -- Calculate dimensions 75 | local width = math.min(#full_message + 4, 60) 76 | local height = 1 77 | 78 | -- Add height for actions 79 | if #actions > 0 then 80 | height = height + 1 + 1 -- Empty line + action line 81 | end 82 | 83 | -- Create floating window 84 | local float = floating.create_notification_float({ 85 | width = width, 86 | height = height, 87 | timeout = timeout > 0 and timeout or nil, 88 | }) 89 | 90 | local bufnr = float.bufnr 91 | local winid = float.winid 92 | 93 | -- Set content 94 | local lines = { full_message } 95 | 96 | if #actions > 0 then 97 | table.insert(lines, '') 98 | local action_text = '' 99 | for i, action in ipairs(actions) do 100 | if i > 1 then 101 | action_text = action_text .. ' ' 102 | end 103 | action_text = action_text .. '<' .. action.label .. '>' 104 | end 105 | table.insert(lines, action_text) 106 | end 107 | 108 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 109 | vim.bo[bufnr].modifiable = false 110 | 111 | -- Apply highlighting 112 | local ns = vim.api.nvim_create_namespace('github_notification') 113 | local hl_group = highlights[type] or highlights.info 114 | vim.api.nvim_buf_add_highlight(bufnr, ns, hl_group, 0, 0, -1) 115 | 116 | -- Setup action keymaps 117 | if #actions > 0 then 118 | for i, action in ipairs(actions) do 119 | vim.keymap.set('n', tostring(i), function() 120 | float.close() 121 | if action.callback then 122 | action.callback() 123 | end 124 | -- Process next notification 125 | vim.defer_fn(M.process_queue, 100) 126 | end, { buffer = bufnr, silent = true }) 127 | end 128 | 129 | -- Override ESC to process queue 130 | vim.keymap.set('n', '', function() 131 | float.close() 132 | vim.defer_fn(M.process_queue, 100) 133 | end, { buffer = bufnr, silent = true }) 134 | else 135 | -- Auto-process next after timeout 136 | if timeout > 0 then 137 | vim.defer_fn(function() 138 | M.process_queue() 139 | end, timeout) 140 | end 141 | end 142 | 143 | -- Click to dismiss (if no actions) 144 | if #actions == 0 then 145 | vim.keymap.set('n', '', function() 146 | float.close() 147 | M.process_queue() 148 | end, { buffer = bufnr, silent = true }) 149 | end 150 | end 151 | 152 | -- Convenience functions for different types 153 | function M.success(message, opts) 154 | M.notify(message, 'success', opts) 155 | end 156 | 157 | function M.error(message, opts) 158 | M.notify(message, 'error', opts) 159 | end 160 | 161 | function M.warning(message, opts) 162 | M.notify(message, 'warning', opts) 163 | end 164 | 165 | function M.info(message, opts) 166 | M.notify(message, 'info', opts) 167 | end 168 | 169 | -- Setup highlight groups 170 | function M.setup_highlights() 171 | vim.api.nvim_set_hl(0, 'GithubNotifySuccess', { fg = '#3fb950', bold = true }) 172 | vim.api.nvim_set_hl(0, 'GithubNotifyError', { fg = '#f85149', bold = true }) 173 | vim.api.nvim_set_hl(0, 'GithubNotifyWarning', { fg = '#d29922', bold = true }) 174 | vim.api.nvim_set_hl(0, 'GithubNotifyInfo', { fg = '#58a6ff', bold = true }) 175 | vim.api.nvim_set_hl(0, 'GithubBackdrop', { bg = '#000000' }) 176 | end 177 | 178 | -- Initialize 179 | M.setup_highlights() 180 | 181 | return M 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neovim GitHub Integration Plugin 2 | 3 | **NOTE:** This was coded with the help of claude-code. I don't take much credits on the codebase (except the idea & UI :)). 4 | 5 | A high-performance Neovim plugin for seamless GitHub integration, providing access to Issues, Pull Requests, Reviews, Comments, and Diffs directly within the editor. Inspired by VSCode/Cursor's GitHub integration, optimized for speed and efficiency. 6 | 7 | ## Screenshots 8 | 9 | **Listing all GitHub PRs:** 10 | 11 | 12 | 13 | **Viewing a PR diff:** 14 | 15 | 16 | 17 | **Viewing a PR comments (starting a PR review):** 18 | 19 | 20 | 21 | ## 🚀 Features 22 | 23 | ### Core Features 24 | - **GitHub Issues Management**: List, view, create, and manage issues 25 | - **Pull Request Management**: View PRs, filter by assignee/reviewer, check CI status 26 | - **Code Review**: Review PRs with inline comments, suggestions, and approvals 27 | - **Diff Viewing**: Side-by-side or unified diff views with syntax highlighting 28 | - **Filtering**: Powerful filtering by assignee, reviewer, label, state, and more 29 | - **Performance**: Optimized for large repos and PRs with caching and lazy loading 30 | 31 | ### Performance Optimizations 32 | - ✅ Virtual scrolling for large lists 33 | - ✅ Incremental diff loading 34 | - ✅ Chunked file display for huge files 35 | - ✅ Response caching with TTL 36 | - ✅ Async operations (non-blocking) 37 | - ✅ Background refresh 38 | - ✅ Debounced search 39 | 40 | ## 📋 Requirements 41 | 42 | - Neovim 0.8+ 43 | - GitHub CLI (`gh`) installed and authenticated 44 | 45 | ## 📦 Installation 46 | 47 | ### Install GitHub CLI 48 | 49 | **macOS:** 50 | ```bash 51 | brew install gh 52 | ``` 53 | 54 | **Linux:** 55 | ```bash 56 | sudo apt install gh # or your package manager 57 | ``` 58 | 59 | **Windows:** 60 | ```bash 61 | winget install GitHub.cli 62 | ``` 63 | 64 | ### Authenticate GitHub CLI 65 | 66 | ```bash 67 | gh auth login 68 | ``` 69 | 70 | ### Install Plugin 71 | 72 | **Using packer.nvim:** 73 | ```lua 74 | use { 75 | 'krshrimali/nvim-github', 76 | config = function() 77 | require('github').setup() 78 | end 79 | } 80 | ``` 81 | 82 | **Using lazy.nvim:** 83 | ```lua 84 | { 85 | 'krshrimali/nvim-github', 86 | config = true, 87 | } 88 | ``` 89 | 90 | **Using vim-plug:** 91 | ```vim 92 | Plug 'krshrimali/nvim-github' 93 | ``` 94 | 95 | ## ⚙️ Configuration 96 | 97 | ### Basic Setup 98 | 99 | ```lua 100 | require('github').setup({ 101 | repo = 'owner/repo', -- or nil for auto-detect 102 | }) 103 | ``` 104 | 105 | ### Advanced Configuration 106 | 107 | See [CONFIG.md](CONFIG.md) for all configuration options. 108 | 109 | ## 🎮 Usage 110 | 111 | ### Quick Start 112 | 113 | ```vim 114 | gh " Open GitHub plugin 115 | ghi " Open Issues list 116 | ghp " Open PRs list 117 | gha " Open assigned to me 118 | ``` 119 | 120 | ### Commands 121 | 122 | ```vim 123 | :Github " Open GitHub plugin 124 | :GithubIssues " Open Issues list 125 | :GithubPRs " Open PRs list 126 | :GithubAssigned " Open assigned items 127 | :GithubRefresh " Refresh current view 128 | ``` 129 | 130 | ### Keymaps 131 | 132 | #### List View 133 | - `j`/`k` - Navigate list 134 | - `` - Open selected item 135 | - `o` - Open in browser 136 | - `f` - Open filter menu 137 | - `/` - Search/filter 138 | - `r` - Refresh 139 | - `q` - Close 140 | 141 | #### Detail View (Issue/PR) 142 | - `` - Toggle expand/collapse section 143 | - `d` - Open diff view 144 | - `c` - Add comment 145 | - `e` - Edit (if author) 146 | - `a` - Assign 147 | - `l` - Add label 148 | - `m` - Merge (PR only) 149 | - `r` - Request review (PR only) 150 | - `q` - Close 151 | 152 | #### Diff View 153 | - `j`/`k` - Navigate lines 154 | - `]c` - Next change 155 | - `[c` - Previous change 156 | - `+` - Expand collapsed chunk 157 | - `-` - Collapse chunk 158 | - `c` - Add comment on line 159 | - `a` - Approve PR 160 | - `r` - Request changes 161 | - `n` - Next file 162 | - `p` - Previous file 163 | - `q` - Close 164 | 165 | See [QUICKSTART.md](QUICKSTART.md) for detailed usage guide. 166 | 167 | ## 🤝 Contributing 168 | 169 | Contributions are welcome! Please: 170 | 171 | 1. Fork the repository 172 | 2. Create a feature branch 173 | 3. Make your changes 174 | 4. Test thoroughly 175 | 5. Submit a PR with description 176 | 177 | See [QUICKSTART.md](QUICKSTART.md) for development setup. 178 | 179 | ## 🔮 Future Enhancements 180 | 181 | - [ ] Notifications support 182 | - [ ] GitHub Actions integration 183 | - [ ] GitHub Discussions support 184 | - [ ] Projects integration 185 | - [ ] Real-time updates via webhooks 186 | - [ ] Multi-repo support 187 | - [ ] Custom themes 188 | - [ ] Markdown preview 189 | - [ ] Code search 190 | 191 | --- 192 | 193 | **Status**: ✅ Implemented 194 | 195 | The plugin is fully implemented and ready to use! All core features are working: 196 | - ✅ Issues and PRs listing with filtering 197 | - ✅ Detail views for issues and PRs 198 | - ✅ Diff viewing with chunking for large files 199 | - ✅ Comments and reviews 200 | - ✅ Caching and performance optimizations 201 | - ✅ Virtual scrolling 202 | - ✅ Configurable keymaps 203 | 204 | See [QUICKSTART.md](QUICKSTART.md) to get started! 205 | -------------------------------------------------------------------------------- /lua/github/config.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local default_config = { 4 | gh_path = 'gh', 5 | repo = nil, 6 | cache = { 7 | enabled = true, 8 | ttl = { 9 | list = 300, 10 | detail = 60, 11 | diff = 300, 12 | }, 13 | directory = vim.fn.stdpath('cache') .. '/github', 14 | max_size = 100, 15 | }, 16 | ui = { 17 | list_height = 20, 18 | detail_split = 'vertical', 19 | diff_split = 'vertical', 20 | syntax_highlight = true, 21 | virtual_scroll = true, 22 | number = true, 23 | relativenumber = false, 24 | statusline = { 25 | enabled = true, 26 | format = 'GitHub: %s', 27 | }, 28 | floating = { 29 | enabled = true, 30 | border = 'rounded', 31 | backdrop = true, 32 | backdrop_opacity = 0.3, 33 | }, 34 | icons = { 35 | enabled = true, 36 | state = { 37 | open = '●', 38 | closed = '○', 39 | merged = '◆', 40 | draft = '◇', 41 | }, 42 | ci = { 43 | success = '✓', 44 | failure = '✗', 45 | pending = '⏳', 46 | }, 47 | }, 48 | }, 49 | performance = { 50 | large_file_threshold = 1000, 51 | chunk_size = 500, 52 | items_per_page = 50, 53 | debounce_ms = 300, 54 | max_concurrent_jobs = 3, 55 | }, 56 | default_filters = { 57 | issues = { 58 | state = 'open', 59 | assignee = nil, 60 | labels = {}, 61 | author = nil, 62 | search = '', 63 | }, 64 | prs = { 65 | state = 'open', 66 | assignee = nil, 67 | reviewer = nil, 68 | labels = {}, 69 | author = nil, 70 | search = '', 71 | }, 72 | }, 73 | keymaps = { 74 | open = 'gh', 75 | issues = 'ghi', 76 | prs = 'ghp', 77 | assigned = 'gha', 78 | refresh = 'ghr', 79 | list = { 80 | next = 'j', 81 | prev = 'k', 82 | open = '', 83 | open_browser = 'o', 84 | filter = 'f', 85 | search = '/', 86 | refresh = 'r', 87 | close = 'q', 88 | }, 89 | detail = { 90 | toggle_section = '', 91 | diff = 'd', 92 | comment = 'c', 93 | edit = 'e', 94 | assign = 'a', 95 | label = 'l', 96 | merge = 'm', 97 | request_review = 'R', 98 | close = 'q', 99 | }, 100 | diff = { 101 | next_change = ']c', 102 | prev_change = '[c', 103 | next_file = 'n', 104 | prev_file = 'p', 105 | expand_chunk = '+', 106 | collapse_chunk = '-', 107 | comment = 'c', 108 | approve = 'a', 109 | request_changes = 'r', 110 | close = 'q', 111 | }, 112 | comment = { 113 | submit = '', 114 | cancel = '', 115 | suggestion = '', 116 | }, 117 | }, 118 | highlights = { 119 | issue_open = { fg = '#28a745' }, 120 | issue_closed = { fg = '#cb2431' }, 121 | pr_open = { fg = '#28a745' }, 122 | pr_closed = { fg = '#cb2431' }, 123 | pr_merged = { fg = '#6f42c1' }, 124 | pr_draft = { fg = '#6a737d' }, 125 | label_bug = { fg = '#ffffff', bg = '#d73a4a' }, 126 | label_enhancement = { fg = '#ffffff', bg = '#a2eeef' }, 127 | diff_add = { fg = '#28a745' }, 128 | diff_delete = { fg = '#cb2431' }, 129 | diff_change = { fg = '#0366d6' }, 130 | review_approved = { fg = '#28a745' }, 131 | review_changes = { fg = '#d73a4a' }, 132 | review_comment = { fg = '#0366d6' }, 133 | }, 134 | notifications = { 135 | enabled = true, 136 | level = 'info', 137 | timeout = 3000, 138 | }, 139 | markdown = { 140 | enabled = true, 141 | code_syntax = true, 142 | max_preview_lines = 1000, 143 | }, 144 | auto_refresh = { 145 | enabled = false, 146 | interval = 60, 147 | notify_on_change = true, 148 | auto_update = false, 149 | }, 150 | actions = { 151 | enabled = true, 152 | show_in_pr = true, 153 | auto_expand_failed = true, 154 | }, 155 | telescope = { 156 | enabled = true, 157 | theme = 'dropdown', 158 | }, 159 | advanced = { 160 | use_treesitter = true, 161 | debug = false, 162 | debug_log = vim.fn.stdpath('cache') .. '/github/debug.log', 163 | gh_args = {}, 164 | }, 165 | } 166 | 167 | M.config = vim.deepcopy(default_config) 168 | 169 | function M.setup(opts) 170 | opts = opts or {} 171 | M.config = vim.tbl_deep_extend('force', default_config, opts) 172 | 173 | -- Ensure cache directory exists 174 | if M.config.cache.enabled then 175 | vim.fn.mkdir(M.config.cache.directory, 'p') 176 | end 177 | 178 | -- Setup highlights 179 | M.setup_highlights() 180 | 181 | -- Validate config 182 | M.validate() 183 | end 184 | 185 | function M.setup_highlights() 186 | for name, hl in pairs(M.config.highlights) do 187 | vim.api.nvim_set_hl(0, 'Github' .. name:gsub('_(%w)', function(c) return c:upper() end), hl) 188 | end 189 | end 190 | 191 | function M.validate() 192 | -- Validate gh_path 193 | if vim.fn.executable(M.config.gh_path) == 0 then 194 | vim.notify('GitHub CLI not found at: ' .. M.config.gh_path, vim.log.levels.WARN) 195 | end 196 | 197 | -- Validate TTL values 198 | for _, ttl in pairs(M.config.cache.ttl) do 199 | if type(ttl) ~= 'number' or ttl < 0 then 200 | error('Invalid TTL value: ' .. tostring(ttl)) 201 | end 202 | end 203 | 204 | -- Validate split directions 205 | local valid_splits = { 'vertical', 'horizontal', 'replace' } 206 | if not vim.tbl_contains(valid_splits, M.config.ui.detail_split) then 207 | error('Invalid detail_split: ' .. M.config.ui.detail_split) 208 | end 209 | if not vim.tbl_contains(valid_splits, M.config.ui.diff_split) then 210 | error('Invalid diff_split: ' .. M.config.ui.diff_split) 211 | end 212 | end 213 | 214 | function M.get_repo() 215 | if M.config.repo then 216 | return M.config.repo 217 | end 218 | 219 | -- Auto-detect from git 220 | local ok, result = pcall(function() 221 | return vim.fn.system('git remote get-url origin'):gsub('.*github.com[:/](.*)%.git', '%1'):gsub('\n', '') 222 | end) 223 | 224 | if ok and result and result ~= '' then 225 | return result 226 | end 227 | 228 | return nil 229 | end 230 | 231 | return M 232 | -------------------------------------------------------------------------------- /lua/github/ui/pr_view.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local buffer = require('github.ui.buffer') 3 | local data_helper = require('github.utils.data_helper') 4 | 5 | function M.render_pr(bufnr, pr) 6 | vim.b[bufnr].github_pr = pr 7 | 8 | local lines = {} 9 | local highlights = {} 10 | 11 | -- Header 12 | local title = pr.title or 'No title' 13 | -- Remove newlines from title (take first line only) 14 | title = vim.split(title, '\n')[1] or title 15 | if pr.isDraft then 16 | title = '[Draft] ' .. title 17 | end 18 | table.insert(lines, string.format('#%d: %s', pr.number, title)) 19 | table.insert(highlights, { 0, 0, -1, 'Title' }) 20 | 21 | -- State and labels 22 | local state = pr.state or 'open' 23 | local state_line = '[' .. (state == 'open' and 'Open' or state == 'closed' and 'Closed' or state == 'merged' and 'Merged' or 'Draft') .. ']' 24 | if pr.labels and #pr.labels > 0 then 25 | local label_names = {} 26 | for _, label in ipairs(pr.labels) do 27 | local label_name = data_helper.safe_get_label_name(label) 28 | table.insert(label_names, label_name) 29 | end 30 | state_line = state_line .. ' ' .. table.concat(label_names, ', ') 31 | end 32 | table.insert(lines, state_line) 33 | 34 | -- Metadata 35 | table.insert(lines, '') 36 | local author = data_helper.safe_get_login(pr.author) 37 | table.insert(lines, string.format('Author: @%s', author)) 38 | 39 | if pr.assignees and #pr.assignees > 0 then 40 | local assignee_names = {} 41 | for _, assignee in ipairs(pr.assignees) do 42 | local assignee_login = data_helper.safe_get_login(assignee) 43 | table.insert(assignee_names, '@' .. assignee_login) 44 | end 45 | table.insert(lines, 'Assignees: ' .. table.concat(assignee_names, ', ')) 46 | end 47 | 48 | -- Reviewers 49 | local reviewer_info = {} 50 | -- Add review requests 51 | if pr.reviewRequests and #pr.reviewRequests > 0 then 52 | for _, req in ipairs(pr.reviewRequests) do 53 | local reviewer = data_helper.safe_get_field(req, 'requestedReviewer') 54 | if reviewer then 55 | local reviewer_login = data_helper.safe_get_login(reviewer) 56 | if reviewer_login ~= 'unknown' then 57 | table.insert(reviewer_info, string.format('○ @%s (requested)', reviewer_login)) 58 | end 59 | end 60 | end 61 | end 62 | -- Add reviews 63 | if pr.reviews and #pr.reviews > 0 then 64 | for _, review in ipairs(pr.reviews) do 65 | local author = data_helper.safe_get_field(review, 'author') 66 | if author then 67 | local author_login = data_helper.safe_get_login(author) 68 | if author_login ~= 'unknown' then 69 | local status = data_helper.safe_get_field(review, 'state', 'pending') 70 | local status_icon = status == 'APPROVED' and '✓' or status == 'CHANGES_REQUESTED' and '✗' or status == 'COMMENTED' and '○' or '○' 71 | table.insert(reviewer_info, string.format('%s @%s (%s)', status_icon, author_login, status:lower())) 72 | end 73 | end 74 | end 75 | end 76 | -- Add review decision if available 77 | if pr.reviewDecision then 78 | table.insert(lines, 'Review Decision: ' .. pr.reviewDecision) 79 | end 80 | if #reviewer_info > 0 then 81 | table.insert(lines, 'Reviewers: ' .. table.concat(reviewer_info, ', ')) 82 | end 83 | 84 | -- CI Status 85 | if pr.statusCheckRollup then 86 | local ci_state = pr.statusCheckRollup.state or 'pending' 87 | local ci_icon = ci_state == 'success' and '✅' or ci_state == 'failure' and '❌' or '⏳' 88 | table.insert(lines, string.format('CI: %s %s', ci_icon, ci_state)) 89 | end 90 | 91 | if pr.createdAt then 92 | table.insert(lines, 'Created: ' .. M.format_date(pr.createdAt)) 93 | end 94 | 95 | -- Body 96 | table.insert(lines, '') 97 | table.insert(lines, '─────────────────────────────────────') 98 | table.insert(lines, '') 99 | 100 | if pr.body and pr.body ~= '' then 101 | local body_lines = vim.split(pr.body, '\n') 102 | for _, line in ipairs(body_lines) do 103 | table.insert(lines, line) 104 | end 105 | else 106 | table.insert(lines, 'No description provided.') 107 | end 108 | 109 | -- Files changed (if available) 110 | if pr.files then 111 | table.insert(lines, '') 112 | table.insert(lines, '─────────────────────────────────────') 113 | table.insert(lines, '') 114 | local total_additions = 0 115 | local total_deletions = 0 116 | for _, file in ipairs(pr.files) do 117 | total_additions = total_additions + (file.additions or 0) 118 | total_deletions = total_deletions + (file.deletions or 0) 119 | end 120 | table.insert(lines, string.format('Files changed: %d (+%d, -%d)', #pr.files, total_additions, total_deletions)) 121 | end 122 | 123 | -- Comments 124 | if pr.comments and #pr.comments > 0 then 125 | table.insert(lines, '') 126 | table.insert(lines, '─────────────────────────────────────') 127 | table.insert(lines, '') 128 | table.insert(lines, string.format('Comments (%d):', #pr.comments)) 129 | table.insert(lines, '') 130 | 131 | for _, comment in ipairs(pr.comments) do 132 | local comment_author = data_helper.safe_get_login(comment.author) 133 | table.insert(lines, string.format('@%s:', comment_author)) 134 | if comment.body then 135 | local comment_lines = vim.split(comment.body, '\n') 136 | for _, line in ipairs(comment_lines) do 137 | table.insert(lines, ' ' .. line) 138 | end 139 | end 140 | if comment.createdAt then 141 | table.insert(lines, ' ── ' .. M.format_date(comment.createdAt)) 142 | end 143 | table.insert(lines, '') 144 | end 145 | end 146 | 147 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 148 | 149 | -- Apply highlights 150 | for _, hl in ipairs(highlights) do 151 | vim.api.nvim_buf_add_highlight(bufnr, 0, hl[4], hl[1], hl[2], hl[3]) 152 | end 153 | end 154 | 155 | function M.format_date(date_str) 156 | if not date_str then 157 | return '' 158 | end 159 | 160 | local year, month, day = string.match(date_str, '(%d+)-(%d+)-(%d+)') 161 | if year and month and day then 162 | return string.format('%s-%s-%s', year, month, day) 163 | end 164 | 165 | return date_str 166 | end 167 | 168 | return M 169 | -------------------------------------------------------------------------------- /lua/github/ui/list_view.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local buffer = require('github.ui.buffer') 3 | local virtual_scroll = require('github.utils.virtual_scroll') 4 | local config = require('github.config') 5 | local data_helper = require('github.utils.data_helper') 6 | 7 | function M.render_list(bufnr, items, selected_idx, filters) 8 | selected_idx = selected_idx or 1 9 | filters = filters or {} 10 | 11 | vim.b[bufnr].github_items = items 12 | vim.b[bufnr].github_selected_idx = selected_idx 13 | vim.b[bufnr].github_filters = filters 14 | 15 | local lines = {} 16 | local highlights = {} 17 | 18 | -- Header with filter state 19 | local filter_type = filters.type or 'Items' 20 | local state_display = '' 21 | if filters.state then 22 | if filters.state == 'all' then 23 | state_display = ' (all)' 24 | elseif filters.state == 'closed' then 25 | state_display = ' (closed)' 26 | else 27 | state_display = ' (open)' 28 | end 29 | end 30 | table.insert(lines, 'GitHub ' .. filter_type .. state_display .. ' (' .. #items .. ')') 31 | table.insert(highlights, { 0, 0, -1, 'GithubHeader' }) 32 | 33 | if #items == 0 then 34 | table.insert(lines, '') 35 | table.insert(lines, 'No items found.') 36 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 37 | return 38 | end 39 | 40 | -- Render items with virtual scrolling 41 | local window_height = vim.api.nvim_win_get_height(0) 42 | local scroll_offset = vim.b[bufnr].github_scroll_offset or 0 43 | 44 | if config.config.ui.virtual_scroll and #items > window_height then 45 | -- Use virtual scrolling for large lists 46 | local start_idx, end_idx = virtual_scroll.get_visible_range(#items, window_height - 2, scroll_offset) 47 | 48 | -- Add padding lines if scrolled 49 | if scroll_offset > 0 then 50 | table.insert(lines, '... (' .. scroll_offset .. ' items above) ...') 51 | end 52 | 53 | for i = start_idx, end_idx do 54 | local item = items[i] 55 | local line = M.format_item(item, i == selected_idx) 56 | table.insert(lines, line) 57 | M.add_item_highlights(highlights, #lines - 1, item, i == selected_idx) 58 | end 59 | 60 | if end_idx < #items then 61 | table.insert(lines, '... (' .. (#items - end_idx) .. ' items below) ...') 62 | end 63 | 64 | vim.b[bufnr].github_scroll_offset = scroll_offset 65 | else 66 | -- For small lists, render all items 67 | for i, item in ipairs(items) do 68 | local line = M.format_item(item, i == selected_idx) 69 | table.insert(lines, line) 70 | M.add_item_highlights(highlights, #lines - 1, item, i == selected_idx) 71 | end 72 | end 73 | 74 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 75 | 76 | -- Apply highlights 77 | for _, hl in ipairs(highlights) do 78 | vim.api.nvim_buf_add_highlight(bufnr, 0, hl[4], hl[1], hl[2], hl[3]) 79 | end 80 | end 81 | 82 | function M.format_item(item, selected) 83 | local prefix = selected and '> ' or ' ' 84 | local number = item.number or item.id or '' 85 | local state = item.state or 'open' 86 | 87 | -- Determine state icon - check draft status first 88 | local state_icon 89 | if item.isDraft then 90 | state_icon = '[Draft]' 91 | elseif state == 'open' then 92 | state_icon = '[Open]' 93 | elseif state == 'closed' then 94 | state_icon = '[Closed]' 95 | elseif state == 'merged' then 96 | state_icon = '[Merged]' 97 | else 98 | state_icon = '[' .. state:upper() .. ']' 99 | end 100 | 101 | local title = item.title or 'No title' 102 | local author = data_helper.safe_get_login(item.author) 103 | 104 | -- Remove newlines from title (take first line only) 105 | title = vim.split(title, '\n')[1] or title 106 | 107 | -- Truncate title if too long 108 | local max_title_len = 50 109 | if #title > max_title_len then 110 | title = string.sub(title, 1, max_title_len - 3) .. '...' 111 | end 112 | 113 | -- Format labels 114 | local labels_str = '' 115 | if item.labels and #item.labels > 0 then 116 | local label_names = {} 117 | for _, label in ipairs(item.labels) do 118 | local label_name = data_helper.safe_get_label_name(label) 119 | table.insert(label_names, label_name) 120 | end 121 | labels_str = ' ' .. table.concat(label_names, ', ') 122 | end 123 | 124 | return string.format('%s#%s %s %s @%s%s', prefix, number, state_icon, title, author, labels_str) 125 | end 126 | 127 | function M.add_item_highlights(highlights, line_num, item, selected) 128 | local ns = vim.api.nvim_create_namespace('github_list') 129 | 130 | if selected then 131 | table.insert(highlights, { line_num, 0, -1, 'Visual' }) 132 | end 133 | 134 | -- State highlight - check draft status first 135 | if item.isDraft then 136 | table.insert(highlights, { line_num, 0, -1, 'GithubPrDraft' }) 137 | else 138 | local state = item.state or 'open' 139 | if state == 'open' then 140 | table.insert(highlights, { line_num, 0, -1, 'GithubIssueOpen' }) 141 | elseif state == 'closed' then 142 | table.insert(highlights, { line_num, 0, -1, 'GithubIssueClosed' }) 143 | elseif state == 'merged' then 144 | table.insert(highlights, { line_num, 0, -1, 'GithubPrMerged' }) 145 | end 146 | end 147 | end 148 | 149 | function M.update_selection(bufnr, selected_idx) 150 | local items = vim.b[bufnr].github_items 151 | if not items or selected_idx < 1 or selected_idx > #items then 152 | return 153 | end 154 | 155 | vim.b[bufnr].github_selected_idx = selected_idx 156 | 157 | -- Scroll to item if using virtual scroll 158 | if config.config.ui.virtual_scroll then 159 | local window_height = vim.api.nvim_win_get_height(0) 160 | local scroll_offset = virtual_scroll.scroll_to_item_idx(selected_idx, #items, window_height - 2) 161 | vim.b[bufnr].github_scroll_offset = scroll_offset 162 | end 163 | 164 | -- Re-render 165 | local filters = vim.b[bufnr].github_filters or {} 166 | M.render_list(bufnr, items, selected_idx, filters) 167 | end 168 | 169 | function M.scroll_to_item(bufnr, idx) 170 | M.update_selection(bufnr, idx) 171 | end 172 | 173 | function M.get_selected_item(bufnr) 174 | local items = vim.b[bufnr].github_items 175 | local selected_idx = vim.b[bufnr].github_selected_idx or 1 176 | 177 | if items and selected_idx >= 1 and selected_idx <= #items then 178 | return items[selected_idx], selected_idx 179 | end 180 | 181 | return nil, nil 182 | end 183 | 184 | return M 185 | -------------------------------------------------------------------------------- /lua/github/filters/filter_manager.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | local current_filters = { 5 | issues = vim.deepcopy(config.config.default_filters.issues), 6 | prs = vim.deepcopy(config.config.default_filters.prs), 7 | } 8 | 9 | function M.get_filters(type) 10 | type = type or 'issues' 11 | return vim.deepcopy(current_filters[type] or {}) 12 | end 13 | 14 | function M.set_filters(type, filters) 15 | if not current_filters[type] then 16 | current_filters[type] = {} 17 | end 18 | current_filters[type] = vim.tbl_deep_extend('force', current_filters[type], filters) 19 | end 20 | 21 | function M.reset_filters(type) 22 | type = type or 'issues' 23 | current_filters[type] = vim.deepcopy(config.config.default_filters[type]) 24 | end 25 | 26 | function M.apply_filters(items, filters, type) 27 | type = type or 'issues' 28 | local filtered = {} 29 | 30 | for _, item in ipairs(items) do 31 | local include = true 32 | 33 | -- State filter 34 | if filters.state and filters.state ~= 'all' then 35 | if item.state ~= filters.state then 36 | include = false 37 | end 38 | end 39 | 40 | -- Assignee filter 41 | if filters.assignee then 42 | if filters.assignee == 'me' then 43 | local has_me = false 44 | local current_user = vim.g.github_current_user 45 | 46 | -- Fetch current user if not cached 47 | if not current_user then 48 | require('github.api.client').get_current_user(function(success, username) 49 | if success then 50 | vim.g.github_current_user = vim.trim(username) 51 | end 52 | end) 53 | -- For first run, accept any assignee since we don't know current user yet 54 | -- This will be corrected on next filter application 55 | if item.assignees and #item.assignees > 0 then 56 | has_me = true 57 | end 58 | else 59 | -- Check if current user is in assignees 60 | if item.assignees then 61 | for _, assignee in ipairs(item.assignees) do 62 | if assignee.login == current_user then 63 | has_me = true 64 | break 65 | end 66 | end 67 | end 68 | end 69 | 70 | if not has_me then 71 | include = false 72 | end 73 | elseif item.assignees then 74 | local has_assignee = false 75 | for _, assignee in ipairs(item.assignees) do 76 | if assignee.login == filters.assignee then 77 | has_assignee = true 78 | break 79 | end 80 | end 81 | if not has_assignee then 82 | include = false 83 | end 84 | end 85 | end 86 | 87 | -- Reviewer filter (PRs only) 88 | if type == 'prs' and filters.reviewer then 89 | if filters.reviewer == 'me' then 90 | local has_me = false 91 | local current_user = vim.g.github_current_user 92 | 93 | -- Fetch current user if not cached 94 | if not current_user then 95 | require('github.api.client').get_current_user(function(success, username) 96 | if success then 97 | vim.g.github_current_user = vim.trim(username) 98 | end 99 | end) 100 | -- For first run, accept if has any reviewer 101 | if (item.reviewRequests and #item.reviewRequests > 0) or (item.reviews and #item.reviews > 0) then 102 | has_me = true 103 | end 104 | else 105 | -- Check if current user is in reviewRequests 106 | if item.reviewRequests then 107 | for _, req in ipairs(item.reviewRequests) do 108 | local reviewer = req.requestedReviewer 109 | if reviewer and reviewer.login == current_user then 110 | has_me = true 111 | break 112 | end 113 | end 114 | end 115 | -- Also check reviews 116 | if not has_me and item.reviews then 117 | for _, review in ipairs(item.reviews) do 118 | if review.author and review.author.login == current_user then 119 | has_me = true 120 | break 121 | end 122 | end 123 | end 124 | end 125 | 126 | if not has_me then 127 | include = false 128 | end 129 | elseif item.reviewRequests or item.reviews then 130 | local has_reviewer = false 131 | -- Check reviewRequests 132 | if item.reviewRequests then 133 | for _, req in ipairs(item.reviewRequests) do 134 | local reviewer = req.requestedReviewer 135 | if reviewer and reviewer.login == filters.reviewer then 136 | has_reviewer = true 137 | break 138 | end 139 | end 140 | end 141 | -- Also check reviews 142 | if not has_reviewer and item.reviews then 143 | for _, review in ipairs(item.reviews) do 144 | if review.author and review.author.login == filters.reviewer then 145 | has_reviewer = true 146 | break 147 | end 148 | end 149 | end 150 | if not has_reviewer then 151 | include = false 152 | end 153 | end 154 | end 155 | 156 | -- Author filter 157 | if filters.author then 158 | if not item.author or item.author.login ~= filters.author then 159 | include = false 160 | end 161 | end 162 | 163 | -- Label filter 164 | if #filters.labels > 0 then 165 | if not item.labels or #item.labels == 0 then 166 | include = false 167 | else 168 | local has_label = false 169 | for _, filter_label in ipairs(filters.labels) do 170 | for _, item_label in ipairs(item.labels) do 171 | if item_label.name == filter_label then 172 | has_label = true 173 | break 174 | end 175 | end 176 | if has_label then 177 | break 178 | end 179 | end 180 | if not has_label then 181 | include = false 182 | end 183 | end 184 | end 185 | 186 | -- Search filter 187 | if filters.search and filters.search ~= '' then 188 | local search_lower = string.lower(filters.search) 189 | local title_match = item.title and string.find(string.lower(item.title), search_lower) 190 | local body_match = item.body and string.find(string.lower(item.body), search_lower) 191 | if not title_match and not body_match then 192 | include = false 193 | end 194 | end 195 | 196 | if include then 197 | table.insert(filtered, item) 198 | end 199 | end 200 | 201 | return filtered 202 | end 203 | 204 | return M 205 | -------------------------------------------------------------------------------- /QUICKSTART.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | ## For Users 4 | 5 | ### Installation 6 | 7 | 1. **Install GitHub CLI** (if not already installed): 8 | ```bash 9 | # macOS 10 | brew install gh 11 | 12 | # Linux 13 | sudo apt install gh # or your package manager 14 | 15 | # Windows 16 | winget install GitHub.cli 17 | ``` 18 | 19 | 2. **Authenticate GitHub CLI**: 20 | ```bash 21 | gh auth login 22 | ``` 23 | 24 | 3. **Install the plugin** (using your favorite plugin manager): 25 | 26 | **packer.nvim:** 27 | ```lua 28 | use { 29 | 'your-username/nvim-github', 30 | config = function() 31 | require('github').setup() 32 | end 33 | } 34 | ``` 35 | 36 | **lazy.nvim:** 37 | ```lua 38 | { 39 | 'your-username/nvim-github', 40 | config = true, 41 | } 42 | ``` 43 | 44 | **vim-plug:** 45 | ```vim 46 | Plug 'your-username/nvim-github' 47 | ``` 48 | 49 | 4. **Configure** (optional): 50 | ```lua 51 | require('github').setup({ 52 | repo = 'owner/repo', -- or leave nil for auto-detect 53 | }) 54 | ``` 55 | 56 | ### Basic Usage 57 | 58 | 1. **Open GitHub plugin**: 59 | - Press `gh` (default) 60 | - Or run `:Github` 61 | 62 | 2. **View Issues**: 63 | - Press `ghi` 64 | - Or run `:GithubIssues` 65 | - Navigate with `j`/`k` 66 | - Press `` to open an issue 67 | 68 | 3. **View PRs**: 69 | - Press `ghp` 70 | - Or run `:GithubPRs` 71 | - Navigate with `j`/`k` 72 | - Press `` to open a PR 73 | 74 | 4. **View Assigned to Me**: 75 | - Press `gha` 76 | - Or run `:GithubAssigned` 77 | 78 | 5. **Filter PRs/Issues**: 79 | - Press `f` in list view 80 | - Select filter option 81 | - Use `/` to search 82 | 83 | 6. **View Diff**: 84 | - Open a PR 85 | - Press `d` to view diff 86 | - Navigate changes with `]c` (next) and `[c` (prev) 87 | 88 | 7. **Add Comment**: 89 | - In PR/Issue detail, press `c` 90 | - Type your comment 91 | - Press `` to submit 92 | 93 | 8. **Review PR**: 94 | - Open PR diff 95 | - Press `a` to approve 96 | - Press `r` to request changes 97 | - Press `c` to add comment 98 | 99 | ### Common Workflows 100 | 101 | #### Review a PR 102 | ``` 103 | 1. ghp # Open PRs list 104 | 2. j/k to navigate # Find PR 105 | 3. # Open PR 106 | 4. d # View diff 107 | 5. ]c/[c # Navigate changes 108 | 6. c on line # Add comment 109 | 7. a # Approve (or r for changes) 110 | ``` 111 | 112 | #### Check Assigned Issues 113 | ``` 114 | 1. gha # Open assigned items 115 | 2. on issue # Open issue 116 | 3. c # Add comment 117 | 4. a # Assign to someone 118 | ``` 119 | 120 | #### Create Issue 121 | ``` 122 | 1. ghi # Open issues 123 | 2. :GithubIssueCreate # Create new issue 124 | 3. Fill in title/body 125 | 4. Submit 126 | ``` 127 | 128 | ## For Developers 129 | 130 | ### Project Setup 131 | 132 | 1. **Clone repository**: 133 | ```bash 134 | git clone https://github.com/your-username/nvim-github.git 135 | cd nvim-github 136 | ``` 137 | 138 | 2. **Development setup**: 139 | ```bash 140 | # Link to Neovim config 141 | ln -s $(pwd) ~/.config/nvim/lua/github 142 | 143 | # Or use as plugin in your config 144 | ``` 145 | 146 | 3. **Start development**: 147 | - Follow the implementation roadmap 148 | - Start with Phase 1 (Core Infrastructure) 149 | - Test incrementally 150 | 151 | ### Development Workflow 152 | 153 | 1. **Create feature branch**: 154 | ```bash 155 | git checkout -b feature/new-feature 156 | ``` 157 | 158 | 2. **Implement feature**: 159 | - Follow the architecture in `PLAN.md` 160 | - Use the API structure from `API_REFERENCE.md` 161 | - Follow code style guidelines 162 | 163 | 3. **Test**: 164 | - Test with real GitHub repos 165 | - Test edge cases (large PRs, many issues) 166 | - Check performance 167 | 168 | 4. **Document**: 169 | - Update relevant docs 170 | - Add examples if needed 171 | 172 | 5. **Submit PR**: 173 | ```bash 174 | git commit -m "Add feature X" 175 | git push origin feature/new-feature 176 | ``` 177 | 178 | ### Testing Checklist 179 | 180 | Before submitting PR: 181 | 182 | - [ ] Plugin loads without errors 183 | - [ ] Can list issues/PRs 184 | - [ ] Can open detail views 185 | - [ ] Can view diffs 186 | - [ ] Can add comments 187 | - [ ] Can approve/request changes 188 | - [ ] Filters work correctly 189 | - [ ] Large PRs load without lag 190 | - [ ] Large files chunk correctly 191 | - [ ] Cache works as expected 192 | - [ ] Error handling works 193 | - [ ] Keymaps are configurable 194 | - [ ] No memory leaks 195 | 196 | ### Debugging 197 | 198 | Enable debug mode: 199 | ```lua 200 | require('github').setup({ 201 | advanced = { 202 | debug = true, 203 | debug_log = '/tmp/github-debug.log', 204 | }, 205 | }) 206 | ``` 207 | 208 | Check logs: 209 | ```bash 210 | tail -f /tmp/github-debug.log 211 | ``` 212 | 213 | ### Common Issues 214 | 215 | **GitHub CLI not found:** 216 | - Ensure `gh` is in PATH 217 | - Or set `gh_path` in config 218 | 219 | **Not authenticated:** 220 | - Run `gh auth login` 221 | - Check with `gh auth status` 222 | 223 | **Slow performance:** 224 | - Check cache is enabled 225 | - Reduce `items_per_page` 226 | - Increase `debounce_ms` 227 | 228 | **Cache issues:** 229 | - Clear cache: `rm -rf ~/.cache/nvim/github` 230 | - Or disable cache temporarily 231 | 232 | ## Architecture Overview 233 | 234 | ``` 235 | User Input 236 | ↓ 237 | Keymaps/Commands 238 | ↓ 239 | UI Layer (buffer/window management) 240 | ↓ 241 | Action Layer (issue/pr/review actions) 242 | ↓ 243 | API Layer (GitHub CLI wrapper) 244 | ↓ 245 | Cache Layer (optional) 246 | ↓ 247 | GitHub CLI (gh) 248 | ↓ 249 | GitHub API 250 | ``` 251 | 252 | ## Key Concepts 253 | 254 | ### Virtual Scrolling 255 | Only render visible items in lists. As user scrolls, load more items. 256 | 257 | ### Chunking 258 | Large files are split into chunks. User expands chunks on-demand. 259 | 260 | ### Caching 261 | API responses are cached with TTL. Cache invalidated on mutations. 262 | 263 | ### Async Operations 264 | All GitHub CLI commands run asynchronously to avoid blocking UI. 265 | 266 | ## Next Steps 267 | 268 | 1. Read `PLAN.md` for overall architecture 269 | 2. Read `IMPLEMENTATION_ROADMAP.md` for technical details 270 | 3. Read `API_REFERENCE.md` for API structure 271 | 4. Read `CONFIG.md` for configuration options 272 | 5. Start implementing Phase 1 273 | 274 | ## Resources 275 | 276 | - [Neovim Lua API](https://neovim.io/doc/user/lua.html) 277 | - [GitHub CLI Documentation](https://cli.github.com/manual/) 278 | - [GitHub API Documentation](https://docs.github.com/en/rest) 279 | 280 | ## Contributing 281 | 282 | 1. Fork the repository 283 | 2. Create feature branch 284 | 3. Make changes 285 | 4. Test thoroughly 286 | 5. Submit PR with description 287 | 288 | ## Support 289 | 290 | - Open an issue for bugs 291 | - Open a discussion for questions 292 | - Check existing issues first 293 | -------------------------------------------------------------------------------- /lua/github/ui/floating.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | 4 | -- Create a centered floating window 5 | function M.create_centered_float(opts) 6 | opts = opts or {} 7 | 8 | local width = opts.width or math.floor(vim.o.columns * 0.8) 9 | local height = opts.height or math.floor(vim.o.lines * 0.8) 10 | local title = opts.title or '' 11 | local border = opts.border or config.config.ui.floating and config.config.ui.floating.border or 'rounded' 12 | local filetype = opts.filetype or '' 13 | 14 | -- Calculate position for center 15 | local row = math.floor((vim.o.lines - height) / 2) 16 | local col = math.floor((vim.o.columns - width) / 2) 17 | 18 | -- Create buffer 19 | local bufnr = vim.api.nvim_create_buf(false, true) 20 | vim.bo[bufnr].bufhidden = 'wipe' 21 | vim.bo[bufnr].buftype = 'nofile' 22 | 23 | if filetype ~= '' then 24 | vim.bo[bufnr].filetype = filetype 25 | end 26 | 27 | -- Create border with title if provided 28 | local border_opts = border 29 | if title ~= '' then 30 | border_opts = { 31 | { '┌', 'FloatBorder' }, 32 | { '─ ' .. title .. ' ', 'FloatTitle' }, 33 | { '┐', 'FloatBorder' }, 34 | { '│', 'FloatBorder' }, 35 | { '┘', 'FloatBorder' }, 36 | { '─', 'FloatBorder' }, 37 | { '└', 'FloatBorder' }, 38 | { '│', 'FloatBorder' }, 39 | } 40 | end 41 | 42 | -- Window options 43 | local win_opts = { 44 | relative = 'editor', 45 | width = width, 46 | height = height, 47 | row = row, 48 | col = col, 49 | style = 'minimal', 50 | border = border_opts, 51 | zindex = 50, 52 | } 53 | 54 | -- Create window 55 | local winid = vim.api.nvim_open_win(bufnr, true, win_opts) 56 | 57 | -- Window-local options 58 | vim.wo[winid].wrap = opts.wrap or false 59 | vim.wo[winid].cursorline = opts.cursorline or true 60 | 61 | -- Create backdrop if enabled 62 | local backdrop_bufnr, backdrop_winid 63 | if config.config.ui.floating and config.config.ui.floating.backdrop then 64 | backdrop_bufnr, backdrop_winid = M.create_backdrop(opts.backdrop_opacity or 0.3) 65 | end 66 | 67 | -- Auto-close on focus lost if enabled 68 | if opts.auto_close ~= false then 69 | vim.api.nvim_create_autocmd('WinLeave', { 70 | buffer = bufnr, 71 | once = true, 72 | callback = function() 73 | if vim.api.nvim_win_is_valid(winid) then 74 | vim.api.nvim_win_close(winid, true) 75 | end 76 | if backdrop_winid and vim.api.nvim_win_is_valid(backdrop_winid) then 77 | vim.api.nvim_win_close(backdrop_winid, true) 78 | end 79 | end, 80 | }) 81 | end 82 | 83 | -- ESC to close 84 | vim.keymap.set('n', '', function() 85 | if vim.api.nvim_win_is_valid(winid) then 86 | vim.api.nvim_win_close(winid, true) 87 | end 88 | if backdrop_winid and vim.api.nvim_win_is_valid(backdrop_winid) then 89 | vim.api.nvim_win_close(backdrop_winid, true) 90 | end 91 | end, { buffer = bufnr, silent = true }) 92 | 93 | return { 94 | bufnr = bufnr, 95 | winid = winid, 96 | backdrop_winid = backdrop_winid, 97 | close = function() 98 | if vim.api.nvim_win_is_valid(winid) then 99 | vim.api.nvim_win_close(winid, true) 100 | end 101 | if backdrop_winid and vim.api.nvim_win_is_valid(backdrop_winid) then 102 | vim.api.nvim_win_close(backdrop_winid, true) 103 | end 104 | end, 105 | } 106 | end 107 | 108 | -- Create a small input floating window (bottom-center) 109 | function M.create_input_float(opts) 110 | opts = opts or {} 111 | 112 | local width = opts.width or math.floor(vim.o.columns * 0.5) 113 | local height = opts.height or 1 114 | local title = opts.title or 'Input' 115 | local border = opts.border or config.config.ui.floating and config.config.ui.floating.border or 'rounded' 116 | 117 | -- Position at bottom-center 118 | local row = vim.o.lines - height - 4 119 | local col = math.floor((vim.o.columns - width) / 2) 120 | 121 | -- Use centered float with custom dimensions and position 122 | opts.width = width 123 | opts.height = height 124 | opts.title = title 125 | opts.border = border 126 | opts.row = row 127 | opts.col = col 128 | 129 | return M.create_centered_float(opts) 130 | end 131 | 132 | -- Create a menu floating window (for selections) 133 | function M.create_menu_float(opts) 134 | opts = opts or {} 135 | 136 | local width = opts.width or 60 137 | local height = opts.height or 20 138 | local title = opts.title or 'Menu' 139 | 140 | opts.width = width 141 | opts.height = height 142 | opts.title = title 143 | opts.cursorline = true 144 | 145 | return M.create_centered_float(opts) 146 | end 147 | 148 | -- Create a notification floating window (top-right corner) 149 | function M.create_notification_float(opts) 150 | opts = opts or {} 151 | 152 | local width = opts.width or 50 153 | local height = opts.height or 3 154 | local border = opts.border or config.config.ui.floating and config.config.ui.floating.border or 'rounded' 155 | 156 | -- Position at top-right 157 | local row = 1 158 | local col = vim.o.columns - width - 2 159 | 160 | -- Create buffer 161 | local bufnr = vim.api.nvim_create_buf(false, true) 162 | vim.bo[bufnr].bufhidden = 'wipe' 163 | vim.bo[bufnr].buftype = 'nofile' 164 | 165 | -- Window options 166 | local win_opts = { 167 | relative = 'editor', 168 | width = width, 169 | height = height, 170 | row = row, 171 | col = col, 172 | style = 'minimal', 173 | border = border, 174 | zindex = 100, -- Higher than other floats 175 | } 176 | 177 | -- Create window 178 | local winid = vim.api.nvim_open_win(bufnr, false, win_opts) -- Don't enter window 179 | 180 | -- Auto-dismiss after timeout 181 | if opts.timeout then 182 | vim.defer_fn(function() 183 | if vim.api.nvim_win_is_valid(winid) then 184 | vim.api.nvim_win_close(winid, true) 185 | end 186 | end, opts.timeout) 187 | end 188 | 189 | return { 190 | bufnr = bufnr, 191 | winid = winid, 192 | close = function() 193 | if vim.api.nvim_win_is_valid(winid) then 194 | vim.api.nvim_win_close(winid, true) 195 | end 196 | end, 197 | } 198 | end 199 | 200 | -- Create a backdrop (dimmed background) 201 | function M.create_backdrop(opacity) 202 | opacity = opacity or 0.3 203 | 204 | -- Create buffer 205 | local bufnr = vim.api.nvim_create_buf(false, true) 206 | vim.bo[bufnr].bufhidden = 'wipe' 207 | 208 | -- Fill buffer with spaces 209 | local lines = {} 210 | for i = 1, vim.o.lines do 211 | table.insert(lines, string.rep(' ', vim.o.columns)) 212 | end 213 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 214 | 215 | -- Window options (full screen) 216 | local win_opts = { 217 | relative = 'editor', 218 | width = vim.o.columns, 219 | height = vim.o.lines, 220 | row = 0, 221 | col = 0, 222 | style = 'minimal', 223 | focusable = false, 224 | zindex = 1, -- Behind floating windows 225 | } 226 | 227 | -- Create window 228 | local winid = vim.api.nvim_open_win(bufnr, false, win_opts) 229 | 230 | -- Set window options for backdrop effect 231 | vim.wo[winid].winhighlight = 'Normal:GithubBackdrop' 232 | vim.wo[winid].winblend = math.floor(opacity * 100) 233 | 234 | return bufnr, winid 235 | end 236 | 237 | return M 238 | -------------------------------------------------------------------------------- /lua/github/ui/diff_view.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local buffer = require('github.ui.buffer') 3 | local diff_parser = require('github.utils.diff_parser') 4 | local diff_chunk = require('github.utils.diff_chunk') 5 | local config = require('github.config') 6 | 7 | function M.render_diff(bufnr, diff_text, file_path) 8 | local files = diff_parser.parse_unified_diff(diff_text) 9 | 10 | if #files == 0 then 11 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'No changes found.' }) 12 | return 13 | end 14 | 15 | vim.b[bufnr].github_diff_files = files 16 | vim.b[bufnr].github_current_file_idx = 1 17 | 18 | -- Render first file 19 | if file_path then 20 | for i, file in ipairs(files) do 21 | if file.file == file_path then 22 | vim.b[bufnr].github_current_file_idx = i 23 | break 24 | end 25 | end 26 | end 27 | 28 | M.render_file(bufnr, files[vim.b[bufnr].github_current_file_idx]) 29 | end 30 | 31 | function M.render_file(bufnr, file_data) 32 | if not file_data then 33 | return 34 | end 35 | 36 | local lines = {} 37 | local highlights = {} 38 | 39 | -- File header 40 | table.insert(lines, string.format('File: %s', file_data.file)) 41 | table.insert(lines, string.format('+%d -%d (%d changes)', file_data.additions or 0, file_data.deletions or 0, file_data.changes or 0)) 42 | table.insert(lines, '─────────────────────────────────────') 43 | table.insert(lines, '') 44 | 45 | -- Check if file is large 46 | local total_lines = 0 47 | if file_data.hunks then 48 | for _, hunk in ipairs(file_data.hunks) do 49 | total_lines = total_lines + math.max(hunk.old_count or 0, hunk.new_count or 0) 50 | end 51 | end 52 | 53 | file_data.is_large = total_lines > config.config.performance.large_file_threshold 54 | 55 | if file_data.is_large then 56 | -- Chunk the file 57 | local chunks = diff_chunk.chunk_file(file_data) 58 | vim.b[bufnr].github_diff_chunks = chunks 59 | 60 | for chunk_idx, chunk in ipairs(chunks) do 61 | if chunk.is_expanded then 62 | M.render_chunk(lines, highlights, chunk, chunk_idx, #chunks) 63 | else 64 | table.insert(lines, string.format('... [Chunk %d/%d - Click + to expand] ...', chunk_idx, #chunks)) 65 | end 66 | end 67 | else 68 | -- Render all hunks 69 | if file_data.hunks then 70 | for _, hunk in ipairs(file_data.hunks) do 71 | M.render_hunk(lines, highlights, hunk) 72 | end 73 | end 74 | end 75 | 76 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 77 | 78 | -- Apply highlights 79 | local ns = vim.api.nvim_create_namespace('github_diff') 80 | for _, hl in ipairs(highlights) do 81 | vim.api.nvim_buf_add_highlight(bufnr, ns, hl[4], hl[1], hl[2], hl[3]) 82 | end 83 | end 84 | 85 | function M.render_chunk(lines, highlights, chunk, chunk_idx, total_chunks) 86 | table.insert(lines, string.format('─── Chunk %d/%d ───', chunk_idx, total_chunks)) 87 | 88 | for _, hunk in ipairs(chunk.hunks) do 89 | M.render_hunk(lines, highlights, hunk) 90 | end 91 | 92 | table.insert(lines, '') 93 | end 94 | 95 | function M.render_hunk(lines, highlights, hunk) 96 | -- Hunk header 97 | table.insert(lines, string.format('@@ -%d,%d +%d,%d @@', hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count)) 98 | table.insert(highlights, { #lines - 1, 0, -1, 'DiffChange' }) 99 | 100 | -- Hunk lines 101 | if hunk.lines then 102 | for _, line_data in ipairs(hunk.lines) do 103 | local prefix = '' 104 | local hl_group = 'Normal' 105 | 106 | if line_data.type == 'deletion' then 107 | prefix = '-' 108 | hl_group = 'GithubDiffDelete' 109 | elseif line_data.type == 'addition' then 110 | prefix = '+' 111 | hl_group = 'GithubDiffAdd' 112 | else 113 | prefix = ' ' 114 | end 115 | 116 | table.insert(lines, prefix .. line_data.content) 117 | table.insert(highlights, { #lines - 1, 0, 1, hl_group }) 118 | end 119 | end 120 | 121 | table.insert(lines, '') 122 | end 123 | 124 | function M.expand_chunk(bufnr, chunk_idx) 125 | local chunks = vim.b[bufnr].github_diff_chunks 126 | if not chunks then 127 | return false 128 | end 129 | 130 | if diff_chunk.expand_chunk(chunks, chunk_idx) then 131 | local file_data = vim.b[bufnr].github_diff_files[vim.b[bufnr].github_current_file_idx] 132 | M.render_file(bufnr, file_data) 133 | return true 134 | end 135 | 136 | return false 137 | end 138 | 139 | function M.next_file(bufnr) 140 | local files = vim.b[bufnr].github_diff_files 141 | if not files then 142 | return false 143 | end 144 | 145 | local current_idx = vim.b[bufnr].github_current_file_idx or 1 146 | if current_idx < #files then 147 | vim.b[bufnr].github_current_file_idx = current_idx + 1 148 | M.render_file(bufnr, files[vim.b[bufnr].github_current_file_idx]) 149 | return true 150 | end 151 | 152 | return false 153 | end 154 | 155 | function M.prev_file(bufnr) 156 | local files = vim.b[bufnr].github_diff_files 157 | if not files then 158 | return false 159 | end 160 | 161 | local current_idx = vim.b[bufnr].github_current_file_idx or 1 162 | if current_idx > 1 then 163 | vim.b[bufnr].github_current_file_idx = current_idx - 1 164 | M.render_file(bufnr, files[vim.b[bufnr].github_current_file_idx]) 165 | return true 166 | end 167 | 168 | return false 169 | end 170 | 171 | -- Map buffer line to actual file line for commenting 172 | function M.get_file_line_from_buffer_line(bufnr, buffer_line) 173 | local files = vim.b[bufnr].github_diff_files 174 | if not files then 175 | return nil 176 | end 177 | 178 | local current_file_idx = vim.b[bufnr].github_current_file_idx or 1 179 | local file_data = files[current_file_idx] 180 | if not file_data then 181 | return nil 182 | end 183 | 184 | -- Get all buffer lines to analyze 185 | local all_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 186 | if buffer_line < 1 or buffer_line > #all_lines then 187 | return nil 188 | end 189 | 190 | -- Skip file header (first 4 lines: filename, stats, separator, empty line) 191 | if buffer_line <= 4 then 192 | return nil 193 | end 194 | 195 | local line_text = all_lines[buffer_line] 196 | 197 | -- Check if line is a chunk/hunk header or separator 198 | if line_text:match('^──') or line_text:match('^%.%.%.') then 199 | return nil 200 | end 201 | 202 | -- Track through hunks to find file line number 203 | local current_line = 5 -- Start after header 204 | local old_line_number = 0 205 | local new_line_number = 0 206 | 207 | if file_data.hunks then 208 | for _, hunk in ipairs(file_data.hunks) do 209 | -- Check if we're at a hunk header 210 | if current_line == buffer_line then 211 | return nil -- Can't comment on hunk header 212 | end 213 | 214 | -- Hunk header line 215 | current_line = current_line + 1 216 | 217 | -- Initialize line numbers for this hunk 218 | old_line_number = hunk.old_start 219 | new_line_number = hunk.new_start 220 | 221 | -- Process hunk lines 222 | if hunk.lines then 223 | for _, line_data in ipairs(hunk.lines) do 224 | if current_line == buffer_line then 225 | -- Found the target line! 226 | local line_num 227 | local side 228 | 229 | if line_data.type == 'deletion' then 230 | line_num = old_line_number 231 | side = 'left' -- Old file 232 | elseif line_data.type == 'addition' then 233 | line_num = new_line_number 234 | side = 'right' -- New file 235 | else 236 | -- Context line - use new file line number 237 | line_num = new_line_number 238 | side = 'right' 239 | end 240 | 241 | return { 242 | file = file_data.file, 243 | line = line_num, 244 | side = side, 245 | type = line_data.type 246 | } 247 | end 248 | 249 | -- Advance line counters 250 | if line_data.type == 'deletion' then 251 | old_line_number = old_line_number + 1 252 | elseif line_data.type == 'addition' then 253 | new_line_number = new_line_number + 1 254 | else 255 | -- Context line advances both 256 | old_line_number = old_line_number + 1 257 | new_line_number = new_line_number + 1 258 | end 259 | 260 | current_line = current_line + 1 261 | end 262 | end 263 | 264 | -- Empty line after hunk 265 | current_line = current_line + 1 266 | 267 | if current_line > buffer_line then 268 | -- We've passed the target line without finding it 269 | break 270 | end 271 | end 272 | end 273 | 274 | return nil 275 | end 276 | 277 | return M 278 | -------------------------------------------------------------------------------- /lua/github/actions/pr_actions.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local client = require('github.api.client') 3 | local config = require('github.config') 4 | local cache = require('github.api.cache') 5 | 6 | function M.create(repo, title, body, head, base, callback) 7 | callback = callback or function(success, data, error) 8 | if success then 9 | vim.notify('PR created: #' .. data.number, vim.log.levels.INFO) 10 | else 11 | vim.notify('Failed to create PR: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 12 | end 13 | end 14 | 15 | local cmd = { 'pr', 'create', '--repo', repo, '--title', title, '--body', body, '--head', head, '--base', base, '--json', 'number,title,state' } 16 | client.run_command(cmd, function(success, output, error) 17 | if success then 18 | cache.invalidate('prs:' .. repo) 19 | local data = client.parse_json(output) 20 | if callback then 21 | callback(true, data, nil) 22 | end 23 | else 24 | if callback then 25 | callback(false, nil, error) 26 | end 27 | end 28 | end) 29 | end 30 | 31 | function M.add_comment(repo, number, body, line, callback) 32 | callback = callback or function(success, data, error) 33 | if success then 34 | vim.notify('Comment added', vim.log.levels.INFO) 35 | cache.invalidate('pr:' .. repo .. ':' .. number) 36 | cache.invalidate('prs:' .. repo) 37 | else 38 | vim.notify('Failed to add comment: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 39 | end 40 | end 41 | 42 | client.add_pr_comment(repo, number, body, line, callback) 43 | end 44 | 45 | function M.request_review(repo, number, reviewers, callback) 46 | callback = callback or function(success, data, error) 47 | if success then 48 | vim.notify('Review requested', vim.log.levels.INFO) 49 | cache.invalidate('pr:' .. repo .. ':' .. number) 50 | cache.invalidate('prs:' .. repo) 51 | else 52 | vim.notify('Failed to request review: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 53 | end 54 | end 55 | 56 | local reviewers_str = table.concat(reviewers, ',') 57 | local cmd = { 'pr', 'edit', tostring(number), '--repo', repo, '--add-reviewer', reviewers_str } 58 | client.run_command(cmd, callback) 59 | end 60 | 61 | function M.merge(repo, number, method, callback) 62 | method = method or 'merge' 63 | callback = callback or function(success, data, error) 64 | if success then 65 | vim.notify('PR merged', vim.log.levels.INFO) 66 | cache.invalidate('pr:' .. repo .. ':' .. number) 67 | cache.invalidate('prs:' .. repo) 68 | else 69 | vim.notify('Failed to merge PR: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 70 | end 71 | end 72 | 73 | local cmd = { 'pr', 'merge', tostring(number), '--repo', repo, '--' .. method } 74 | client.run_command(cmd, callback) 75 | end 76 | 77 | -- Get current git branch 78 | function M.get_current_branch(callback) 79 | local branch = vim.fn.system('git rev-parse --abbrev-ref HEAD 2>/dev/null'):gsub('\n', '') 80 | if vim.v.shell_error ~= 0 or branch == '' then 81 | branch = 'main' 82 | end 83 | callback(branch) 84 | end 85 | 86 | -- Get branches for repository 87 | function M.get_branches(repo, callback) 88 | local cmd = { 'api', 'repos/' .. repo .. '/branches', '--jq', '.[].name' } 89 | client.run_command(cmd, function(success, output, error) 90 | if success then 91 | local branches = vim.split(output, '\n') 92 | branches = vim.tbl_filter(function(b) return b ~= '' end, branches) 93 | callback(true, branches, nil) 94 | else 95 | callback(false, nil, error) 96 | end 97 | end) 98 | end 99 | 100 | -- Get repository collaborators (for reviewer selection) 101 | function M.get_collaborators(repo, callback) 102 | local cmd = { 'api', 'repos/' .. repo .. '/collaborators', '--jq', '.[].login' } 103 | client.run_command(cmd, function(success, output, error) 104 | if success then 105 | local collaborators = vim.split(output, '\n') 106 | collaborators = vim.tbl_filter(function(c) return c ~= '' end, collaborators) 107 | callback(true, collaborators, nil) 108 | else 109 | callback(false, nil, error) 110 | end 111 | end) 112 | end 113 | 114 | -- Get repository labels 115 | function M.get_labels(repo, callback) 116 | local cmd = { 'label', 'list', '--repo', repo, '--json', 'name', '--jq', '.[].name' } 117 | client.run_command(cmd, function(success, output, error) 118 | if success then 119 | local labels = vim.split(output, '\n') 120 | labels = vim.tbl_filter(function(l) return l ~= '' end, labels) 121 | callback(true, labels, nil) 122 | else 123 | callback(false, nil, error) 124 | end 125 | end) 126 | end 127 | 128 | -- Load PR template from standard locations 129 | function M.load_pr_template(callback) 130 | local template_paths = { 131 | '.github/pull_request_template.md', 132 | '.github/PULL_REQUEST_TEMPLATE.md', 133 | 'docs/pull_request_template.md', 134 | 'PULL_REQUEST_TEMPLATE.md' 135 | } 136 | 137 | for _, path in ipairs(template_paths) do 138 | if vim.fn.filereadable(path) == 1 then 139 | local lines = vim.fn.readfile(path) 140 | callback(table.concat(lines, '\n')) 141 | return 142 | end 143 | end 144 | 145 | callback('') -- No template found 146 | end 147 | 148 | -- Create PR with advanced options (draft, reviewers, labels) 149 | function M.create_advanced(repo, options, callback) 150 | callback = callback or function(success, data, error) 151 | if success then 152 | vim.notify('PR created: #' .. data.number, vim.log.levels.INFO) 153 | else 154 | vim.notify('Failed to create PR: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 155 | end 156 | end 157 | 158 | local cmd = { 159 | 'pr', 'create', 160 | '--repo', repo, 161 | '--title', options.title, 162 | '--body', options.body or '', 163 | '--head', options.head, 164 | '--base', options.base, 165 | '--json', 'number,title,state,url' 166 | } 167 | 168 | if options.draft then 169 | table.insert(cmd, '--draft') 170 | end 171 | 172 | if options.reviewers and #options.reviewers > 0 then 173 | for _, reviewer in ipairs(options.reviewers) do 174 | table.insert(cmd, '--reviewer') 175 | table.insert(cmd, reviewer) 176 | end 177 | end 178 | 179 | if options.labels and #options.labels > 0 then 180 | for _, label in ipairs(options.labels) do 181 | table.insert(cmd, '--label') 182 | table.insert(cmd, label) 183 | end 184 | end 185 | 186 | client.run_command(cmd, function(success, output, error) 187 | if success then 188 | cache.invalidate('prs:' .. repo) 189 | local data = client.parse_json(output) 190 | if callback then 191 | callback(true, data, nil) 192 | end 193 | else 194 | if callback then 195 | callback(false, nil, error) 196 | end 197 | end 198 | end) 199 | end 200 | 201 | -- Update PR metadata (title, body, assignees, labels) 202 | function M.update_pr(repo, number, updates, callback) 203 | callback = callback or function(success, data, error) 204 | if success then 205 | vim.notify('PR updated', vim.log.levels.INFO) 206 | else 207 | vim.notify('Failed to update PR: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 208 | end 209 | end 210 | 211 | local cmd = { 'pr', 'edit', tostring(number), '--repo', repo } 212 | 213 | if updates.title then 214 | table.insert(cmd, '--title') 215 | table.insert(cmd, updates.title) 216 | end 217 | 218 | if updates.body then 219 | table.insert(cmd, '--body') 220 | table.insert(cmd, updates.body) 221 | end 222 | 223 | if updates.add_assignees and #updates.add_assignees > 0 then 224 | for _, assignee in ipairs(updates.add_assignees) do 225 | table.insert(cmd, '--add-assignee') 226 | table.insert(cmd, assignee) 227 | end 228 | end 229 | 230 | if updates.remove_assignees and #updates.remove_assignees > 0 then 231 | for _, assignee in ipairs(updates.remove_assignees) do 232 | table.insert(cmd, '--remove-assignee') 233 | table.insert(cmd, assignee) 234 | end 235 | end 236 | 237 | if updates.add_labels and #updates.add_labels > 0 then 238 | for _, label in ipairs(updates.add_labels) do 239 | table.insert(cmd, '--add-label') 240 | table.insert(cmd, label) 241 | end 242 | end 243 | 244 | if updates.remove_labels and #updates.remove_labels > 0 then 245 | for _, label in ipairs(updates.remove_labels) do 246 | table.insert(cmd, '--remove-label') 247 | table.insert(cmd, label) 248 | end 249 | end 250 | 251 | client.run_command(cmd, function(success, output, error) 252 | if success then 253 | cache.invalidate('pr:' .. repo .. ':' .. number) 254 | cache.invalidate('prs:' .. repo) 255 | if callback then 256 | callback(true, nil, nil) 257 | end 258 | else 259 | if callback then 260 | callback(false, nil, error) 261 | end 262 | end 263 | end) 264 | end 265 | 266 | return M 267 | -------------------------------------------------------------------------------- /lua/github/keymaps.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | local buffer = require('github.ui.buffer') 4 | 5 | function M.setup_keymaps(bufnr, type) 6 | local keymaps = config.config.keymaps 7 | 8 | -- List view keymaps 9 | if type == buffer.BUFFER_TYPES.LIST then 10 | M.map(bufnr, keymaps.list.next, function() 11 | M.list_next(bufnr) 12 | end) 13 | M.map(bufnr, keymaps.list.prev, function() 14 | M.list_prev(bufnr) 15 | end) 16 | M.map(bufnr, keymaps.list.open, function() 17 | M.list_open(bufnr) 18 | end) 19 | M.map(bufnr, keymaps.list.open_browser, function() 20 | M.list_open_browser(bufnr) 21 | end) 22 | M.map(bufnr, keymaps.list.close, function() 23 | M.list_close(bufnr) 24 | end) 25 | M.map(bufnr, keymaps.list.refresh, function() 26 | M.list_refresh(bufnr) 27 | end) 28 | M.map(bufnr, keymaps.list.filter, function() 29 | M.list_filter(bufnr) 30 | end) 31 | M.map(bufnr, keymaps.list.search, function() 32 | M.list_search(bufnr) 33 | end) 34 | end 35 | 36 | -- Detail view keymaps 37 | if type == buffer.BUFFER_TYPES.ISSUE or type == buffer.BUFFER_TYPES.PR then 38 | M.map(bufnr, keymaps.detail.diff, function() 39 | M.detail_diff(bufnr) 40 | end) 41 | M.map(bufnr, keymaps.detail.comment, function() 42 | M.detail_comment(bufnr) 43 | end) 44 | M.map(bufnr, keymaps.detail.close, function() 45 | M.detail_close(bufnr) 46 | end) 47 | end 48 | 49 | -- Diff view keymaps 50 | if type == buffer.BUFFER_TYPES.DIFF then 51 | M.map(bufnr, keymaps.diff.next_change, function() 52 | M.diff_next_change(bufnr) 53 | end) 54 | M.map(bufnr, keymaps.diff.prev_change, function() 55 | M.diff_prev_change(bufnr) 56 | end) 57 | M.map(bufnr, keymaps.diff.next_file, function() 58 | M.diff_next_file(bufnr) 59 | end) 60 | M.map(bufnr, keymaps.diff.prev_file, function() 61 | M.diff_prev_file(bufnr) 62 | end) 63 | M.map(bufnr, keymaps.diff.expand_chunk, function() 64 | M.diff_expand_chunk(bufnr) 65 | end) 66 | M.map(bufnr, keymaps.diff.comment, function() 67 | M.diff_comment(bufnr) 68 | end) 69 | M.map(bufnr, keymaps.diff.approve, function() 70 | M.diff_approve(bufnr) 71 | end) 72 | M.map(bufnr, keymaps.diff.request_changes, function() 73 | M.diff_request_changes(bufnr) 74 | end) 75 | M.map(bufnr, keymaps.diff.close, function() 76 | M.diff_close(bufnr) 77 | end) 78 | end 79 | end 80 | 81 | function M.map(bufnr, key, callback) 82 | if not key or key == false then 83 | return 84 | end 85 | 86 | vim.keymap.set('n', key, callback, { buffer = bufnr, silent = true }) 87 | end 88 | 89 | -- List view actions 90 | function M.list_next(bufnr) 91 | local list_view = require('github.ui.list_view') 92 | local selected_idx = (vim.b[bufnr].github_selected_idx or 1) + 1 93 | local items = vim.b[bufnr].github_items 94 | if items and selected_idx <= #items then 95 | list_view.update_selection(bufnr, selected_idx) 96 | end 97 | end 98 | 99 | function M.list_prev(bufnr) 100 | local list_view = require('github.ui.list_view') 101 | local selected_idx = math.max(1, (vim.b[bufnr].github_selected_idx or 1) - 1) 102 | list_view.update_selection(bufnr, selected_idx) 103 | end 104 | 105 | function M.list_open(bufnr) 106 | local list_view = require('github.ui.list_view') 107 | local item, idx = list_view.get_selected_item(bufnr) 108 | if item then 109 | local main = require('github') 110 | if vim.b[bufnr].github_filters and vim.b[bufnr].github_filters.type == 'prs' then 111 | main.open_pr_detail(item.number) 112 | else 113 | main.open_issue_detail(item.number) 114 | end 115 | end 116 | end 117 | 118 | function M.list_close(bufnr) 119 | local window = require('github.ui.window') 120 | local wins = vim.fn.win_findbuf(bufnr) 121 | for _, winid in ipairs(wins) do 122 | if vim.api.nvim_win_is_valid(winid) then 123 | vim.api.nvim_win_close(winid, false) 124 | end 125 | end 126 | end 127 | 128 | function M.list_refresh(bufnr) 129 | local main = require('github') 130 | if vim.b[bufnr].github_filters and vim.b[bufnr].github_filters.type == 'prs' then 131 | main.open_prs() 132 | else 133 | main.open_issues() 134 | end 135 | end 136 | 137 | function M.list_filter(bufnr) 138 | local filter_manager = require('github.filters.filter_manager') 139 | local filters = vim.b[bufnr].github_filters 140 | if not filters then 141 | return 142 | end 143 | 144 | local filter_type = filters.type or 'issues' 145 | local current_state = filters.state or 'open' 146 | 147 | -- Toggle state: open -> closed -> all -> open 148 | local next_state 149 | if current_state == 'open' then 150 | next_state = 'closed' 151 | elseif current_state == 'closed' then 152 | next_state = 'all' 153 | else 154 | next_state = 'open' 155 | end 156 | 157 | -- Update filter 158 | filter_manager.set_filters(filter_type, { state = next_state }) 159 | 160 | -- Refresh the list 161 | local main = require('github') 162 | if filter_type == 'prs' then 163 | main.open_prs() 164 | else 165 | main.open_issues() 166 | end 167 | 168 | -- Show notification 169 | local state_display = next_state == 'all' and 'all' or (next_state == 'open' and 'open' or 'closed') 170 | vim.notify('Filter: ' .. state_display, vim.log.levels.INFO) 171 | end 172 | 173 | function M.list_open_browser(bufnr) 174 | local list_view = require('github.ui.list_view') 175 | local item, idx = list_view.get_selected_item(bufnr) 176 | if not item then 177 | vim.notify('No item selected', vim.log.levels.WARN) 178 | return 179 | end 180 | 181 | local filters = vim.b[bufnr].github_filters 182 | local filter_type = filters and filters.type or 'issues' 183 | local cmd = filter_type == 'prs' 184 | and { 'pr', 'view', tostring(item.number), '--web' } 185 | or { 'issue', 'view', tostring(item.number), '--web' } 186 | 187 | require('github.api.client').run_command(cmd, function(success, output, error) 188 | if not success then 189 | vim.notify('Failed to open in browser: ' .. (error or ''), vim.log.levels.ERROR) 190 | end 191 | end) 192 | end 193 | 194 | function M.list_search(bufnr) 195 | local input = require('github.ui.input') 196 | local filter_manager = require('github.filters.filter_manager') 197 | local filters = vim.b[bufnr].github_filters or {} 198 | local current_search = filters.search or '' 199 | 200 | input.show_input('Search:', current_search, function(query) 201 | if query == nil then 202 | return -- Cancelled 203 | end 204 | 205 | filters.search = query 206 | filter_manager.set_filters(filters.type or 'issues', filters) 207 | M.list_refresh(bufnr) 208 | end, { placeholder = 'Enter search term...' }) 209 | end 210 | 211 | -- Detail view actions 212 | function M.detail_diff(bufnr) 213 | local main = require('github') 214 | local pr = vim.b[bufnr].github_pr 215 | if pr then 216 | main.open_pr_diff(pr.number) 217 | end 218 | end 219 | 220 | function M.detail_comment(bufnr) 221 | local main = require('github') 222 | local issue = vim.b[bufnr].github_issue 223 | local pr = vim.b[bufnr].github_pr 224 | 225 | if issue then 226 | main.add_issue_comment(issue.number) 227 | elseif pr then 228 | main.add_pr_comment(pr.number) 229 | end 230 | end 231 | 232 | function M.detail_close(bufnr) 233 | local wins = vim.fn.win_findbuf(bufnr) 234 | for _, winid in ipairs(wins) do 235 | if vim.api.nvim_win_is_valid(winid) then 236 | vim.api.nvim_win_close(winid, false) 237 | end 238 | end 239 | end 240 | 241 | -- Diff view actions 242 | function M.diff_next_change(bufnr) 243 | -- Simplified - would need to track hunk positions 244 | vim.cmd(']c') 245 | end 246 | 247 | function M.diff_prev_change(bufnr) 248 | vim.cmd('[c') 249 | end 250 | 251 | function M.diff_next_file(bufnr) 252 | local diff_view = require('github.ui.diff_view') 253 | diff_view.next_file(bufnr) 254 | end 255 | 256 | function M.diff_prev_file(bufnr) 257 | local diff_view = require('github.ui.diff_view') 258 | diff_view.prev_file(bufnr) 259 | end 260 | 261 | function M.diff_expand_chunk(bufnr) 262 | local diff_view = require('github.ui.diff_view') 263 | local chunks = vim.b[bufnr].github_diff_chunks 264 | if chunks then 265 | local current_line = vim.api.nvim_win_get_cursor(0)[1] 266 | -- Find which chunk we're in (simplified) 267 | local chunk_idx = math.floor(current_line / 100) + 1 268 | if chunk_idx <= #chunks then 269 | diff_view.expand_chunk(bufnr, chunk_idx) 270 | end 271 | end 272 | end 273 | 274 | function M.diff_comment(bufnr) 275 | local main = require('github') 276 | local diff_view = require('github.ui.diff_view') 277 | local pr = vim.b[bufnr].github_pr 278 | 279 | if pr then 280 | local current_line = vim.api.nvim_win_get_cursor(0)[1] 281 | local file_line = diff_view.get_file_line_from_buffer_line(bufnr, current_line) 282 | 283 | if file_line then 284 | -- Add comment with file path and line number 285 | main.add_pr_comment(pr.number, file_line.line, file_line.file) 286 | else 287 | vim.notify('Cannot comment on this line (must be on a diff line, not a header)', vim.log.levels.WARN) 288 | end 289 | end 290 | end 291 | 292 | function M.diff_approve(bufnr) 293 | local main = require('github') 294 | local pr = vim.b[bufnr].github_pr 295 | if pr then 296 | main.approve_pr(pr.number) 297 | end 298 | end 299 | 300 | function M.diff_request_changes(bufnr) 301 | local main = require('github') 302 | local pr = vim.b[bufnr].github_pr 303 | if pr then 304 | main.request_changes_pr(pr.number) 305 | end 306 | end 307 | 308 | function M.diff_close(bufnr) 309 | local wins = vim.fn.win_findbuf(bufnr) 310 | for _, winid in ipairs(wins) do 311 | if vim.api.nvim_win_is_valid(winid) then 312 | vim.api.nvim_win_close(winid, false) 313 | end 314 | end 315 | end 316 | 317 | return M 318 | -------------------------------------------------------------------------------- /lua/github/ui/search.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local data_helper = require('github.utils.data_helper') 3 | 4 | -- Fuzzy search functionality with telescope integration 5 | function M.show_pr_search(repo, callback) 6 | local has_telescope, telescope = pcall(require, 'telescope') 7 | 8 | if has_telescope then 9 | M.telescope_pr_search(repo, callback) 10 | else 11 | M.native_pr_search(repo, callback) 12 | end 13 | end 14 | 15 | function M.show_issue_search(repo, callback) 16 | local has_telescope, telescope = pcall(require, 'telescope') 17 | 18 | if has_telescope then 19 | M.telescope_issue_search(repo, callback) 20 | else 21 | M.native_issue_search(repo, callback) 22 | end 23 | end 24 | 25 | -- Telescope integration for PR search 26 | function M.telescope_pr_search(repo, callback) 27 | local pickers = require('telescope.pickers') 28 | local finders = require('telescope.finders') 29 | local conf = require('telescope.config').values 30 | local actions = require('telescope.actions') 31 | local action_state = require('telescope.actions.state') 32 | local previewers = require('telescope.previewers') 33 | 34 | local client = require('github.api.client') 35 | 36 | -- Fetch all PRs 37 | client.list_prs(repo, { state = 'all' }, function(success, prs, error) 38 | if not success or not prs then 39 | vim.notify('Failed to load PRs: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 40 | return 41 | end 42 | 43 | vim.schedule(function() 44 | pickers.new({}, { 45 | prompt_title = 'GitHub Pull Requests', 46 | finder = finders.new_table { 47 | results = prs, 48 | entry_maker = function(pr) 49 | local state_icon = pr.isDraft and '[Draft]' or (pr.state == 'OPEN' and '[Open]' or '[Closed]') 50 | local author = data_helper.safe_get_login(pr.author) 51 | local display = string.format('#%d %s %s - @%s', 52 | pr.number, 53 | state_icon, 54 | pr.title, 55 | author) 56 | 57 | return { 58 | value = pr, 59 | display = display, 60 | ordinal = string.format('%d %s %s', pr.number, pr.title, pr.body or ''), 61 | number = pr.number, 62 | } 63 | end, 64 | }, 65 | sorter = conf.generic_sorter({}), 66 | previewer = previewers.new_buffer_previewer({ 67 | title = 'PR Details', 68 | define_preview = function(self, entry) 69 | local pr = entry.value 70 | local author = data_helper.safe_get_login(pr.author) 71 | local lines = { 72 | string.format('PR #%d', pr.number), 73 | string.format('Title: %s', pr.title), 74 | string.format('Author: @%s', author), 75 | string.format('State: %s', pr.state), 76 | '', 77 | 'Description:', 78 | '─────────────', 79 | } 80 | 81 | if pr.body and pr.body ~= '' then 82 | local body_lines = vim.split(pr.body, '\n') 83 | for _, line in ipairs(body_lines) do 84 | table.insert(lines, line) 85 | end 86 | else 87 | table.insert(lines, 'No description provided.') 88 | end 89 | 90 | vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) 91 | vim.bo[self.state.bufnr].filetype = 'markdown' 92 | end, 93 | }), 94 | attach_mappings = function(prompt_bufnr, map) 95 | actions.select_default:replace(function() 96 | local selection = action_state.get_selected_entry() 97 | actions.close(prompt_bufnr) 98 | if callback then 99 | callback(selection.value) 100 | end 101 | end) 102 | return true 103 | end, 104 | }):find() 105 | end) 106 | end) 107 | end 108 | 109 | -- Telescope integration for issue search 110 | function M.telescope_issue_search(repo, callback) 111 | local pickers = require('telescope.pickers') 112 | local finders = require('telescope.finders') 113 | local conf = require('telescope.config').values 114 | local actions = require('telescope.actions') 115 | local action_state = require('telescope.actions.state') 116 | local previewers = require('telescope.previewers') 117 | 118 | local client = require('github.api.client') 119 | 120 | -- Fetch all issues 121 | client.list_issues(repo, { state = 'all' }, function(success, issues, error) 122 | if not success or not issues then 123 | vim.notify('Failed to load issues: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 124 | return 125 | end 126 | 127 | vim.schedule(function() 128 | pickers.new({}, { 129 | prompt_title = 'GitHub Issues', 130 | finder = finders.new_table { 131 | results = issues, 132 | entry_maker = function(issue) 133 | local state_icon = issue.state == 'open' and '[Open]' or '[Closed]' 134 | local author = data_helper.safe_get_login(issue.author) 135 | local display = string.format('#%d %s %s - @%s', 136 | issue.number, 137 | state_icon, 138 | issue.title, 139 | author) 140 | 141 | return { 142 | value = issue, 143 | display = display, 144 | ordinal = string.format('%d %s %s', issue.number, issue.title, issue.body or ''), 145 | number = issue.number, 146 | } 147 | end, 148 | }, 149 | sorter = conf.generic_sorter({}), 150 | previewer = previewers.new_buffer_previewer({ 151 | title = 'Issue Details', 152 | define_preview = function(self, entry) 153 | local issue = entry.value 154 | local author = data_helper.safe_get_login(issue.author) 155 | local lines = { 156 | string.format('Issue #%d', issue.number), 157 | string.format('Title: %s', issue.title), 158 | string.format('Author: @%s', author), 159 | string.format('State: %s', issue.state), 160 | '', 161 | 'Description:', 162 | '─────────────', 163 | } 164 | 165 | if issue.body and issue.body ~= '' then 166 | local body_lines = vim.split(issue.body, '\n') 167 | for _, line in ipairs(body_lines) do 168 | table.insert(lines, line) 169 | end 170 | else 171 | table.insert(lines, 'No description provided.') 172 | end 173 | 174 | vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) 175 | vim.bo[self.state.bufnr].filetype = 'markdown' 176 | end, 177 | }), 178 | attach_mappings = function(prompt_bufnr, map) 179 | actions.select_default:replace(function() 180 | local selection = action_state.get_selected_entry() 181 | actions.close(prompt_bufnr) 182 | if callback then 183 | callback(selection.value) 184 | end 185 | end) 186 | return true 187 | end, 188 | }):find() 189 | end) 190 | end) 191 | end 192 | 193 | -- Native search (fallback without telescope) 194 | function M.native_pr_search(repo, callback) 195 | local client = require('github.api.client') 196 | 197 | -- Fetch all PRs 198 | client.list_prs(repo, { state = 'all' }, function(success, prs, error) 199 | if not success or not prs then 200 | vim.notify('Failed to load PRs: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 201 | return 202 | end 203 | 204 | vim.schedule(function() 205 | -- Use vim.ui.select for native search 206 | local items = {} 207 | for _, pr in ipairs(prs) do 208 | local state_icon = pr.isDraft and '[Draft]' or (pr.state == 'OPEN' and '[Open]' or '[Closed]') 209 | local author = data_helper.safe_get_login(pr.author) 210 | table.insert(items, { 211 | text = string.format('#%d %s %s - @%s', 212 | pr.number, 213 | state_icon, 214 | pr.title, 215 | author), 216 | pr = pr, 217 | }) 218 | end 219 | 220 | vim.ui.select(items, { 221 | prompt = 'Select Pull Request:', 222 | format_item = function(item) 223 | return item.text 224 | end, 225 | }, function(choice) 226 | if choice and callback then 227 | callback(choice.pr) 228 | end 229 | end) 230 | end) 231 | end) 232 | end 233 | 234 | -- Native issue search (fallback without telescope) 235 | function M.native_issue_search(repo, callback) 236 | local client = require('github.api.client') 237 | 238 | -- Fetch all issues 239 | client.list_issues(repo, { state = 'all' }, function(success, issues, error) 240 | if not success or not issues then 241 | vim.notify('Failed to load issues: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 242 | return 243 | end 244 | 245 | vim.schedule(function() 246 | -- Use vim.ui.select for native search 247 | local items = {} 248 | for _, issue in ipairs(issues) do 249 | local state_icon = issue.state == 'open' and '[Open]' or '[Closed]' 250 | local author = data_helper.safe_get_login(issue.author) 251 | table.insert(items, { 252 | text = string.format('#%d %s %s - @%s', 253 | issue.number, 254 | state_icon, 255 | issue.title, 256 | author), 257 | issue = issue, 258 | }) 259 | end 260 | 261 | vim.ui.select(items, { 262 | prompt = 'Select Issue:', 263 | format_item = function(item) 264 | return item.text 265 | end, 266 | }, function(choice) 267 | if choice and callback then 268 | callback(choice.issue) 269 | end 270 | end) 271 | end) 272 | end) 273 | end 274 | 275 | return M 276 | -------------------------------------------------------------------------------- /lua/github/feature/pr_editor.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local pr_actions = require('github.actions.pr_actions') 3 | local input = require('github.ui.input') 4 | local form_components = require('github.feature.form_components') 5 | 6 | -- Enable edit mode for PR viewer 7 | function M.enable_edit_mode(sidebar_bufnr) 8 | local pr = vim.b[sidebar_bufnr].github_pr 9 | local repo = vim.b[sidebar_bufnr].github_repo 10 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 11 | 12 | if not pr or not repo or not pr_number then 13 | vim.notify('Missing PR data', vim.log.levels.ERROR) 14 | return 15 | end 16 | 17 | vim.b[sidebar_bufnr].github_edit_mode = true 18 | 19 | -- Re-render sidebar with edit indicators 20 | local pr_viewer = require('github.ui.pr_viewer') 21 | local files = vim.b[sidebar_bufnr].github_pr_files or {} 22 | pr_viewer.render_sidebar(sidebar_bufnr, pr, files) 23 | 24 | -- Add edit mode keymaps 25 | M.setup_edit_keymaps(sidebar_bufnr) 26 | 27 | vim.notify('Edit mode enabled! Press "e" again to disable.', vim.log.levels.INFO) 28 | end 29 | 30 | -- Disable edit mode 31 | function M.disable_edit_mode(sidebar_bufnr) 32 | vim.b[sidebar_bufnr].github_edit_mode = false 33 | 34 | local pr_viewer = require('github.ui.pr_viewer') 35 | local pr = vim.b[sidebar_bufnr].github_pr 36 | local files = vim.b[sidebar_bufnr].github_pr_files or {} 37 | pr_viewer.render_sidebar(sidebar_bufnr, pr, files) 38 | 39 | vim.notify('Edit mode disabled', vim.log.levels.INFO) 40 | end 41 | 42 | -- Setup edit mode keymaps 43 | function M.setup_edit_keymaps(sidebar_bufnr) 44 | -- Edit title 45 | vim.keymap.set('n', 'T', function() 46 | M.edit_pr_title(sidebar_bufnr) 47 | end, { buffer = sidebar_bufnr, silent = true }) 48 | 49 | -- Edit description 50 | vim.keymap.set('n', 'D', function() 51 | M.edit_pr_description(sidebar_bufnr) 52 | end, { buffer = sidebar_bufnr, silent = true }) 53 | 54 | -- Manage assignees 55 | vim.keymap.set('n', 'A', function() 56 | M.manage_assignees(sidebar_bufnr) 57 | end, { buffer = sidebar_bufnr, silent = true }) 58 | 59 | -- Manage labels 60 | vim.keymap.set('n', 'L', function() 61 | M.manage_labels(sidebar_bufnr) 62 | end, { buffer = sidebar_bufnr, silent = true }) 63 | 64 | -- Manage reviewers 65 | vim.keymap.set('n', 'R', function() 66 | M.manage_reviewers(sidebar_bufnr) 67 | end, { buffer = sidebar_bufnr, silent = true }) 68 | end 69 | 70 | -- Edit PR title 71 | function M.edit_pr_title(sidebar_bufnr) 72 | local pr = vim.b[sidebar_bufnr].github_pr 73 | local repo = vim.b[sidebar_bufnr].github_repo 74 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 75 | 76 | vim.ui.input({ 77 | prompt = 'New title: ', 78 | default = pr.title, 79 | }, function(new_title) 80 | if not new_title or new_title == '' or new_title == pr.title then 81 | return 82 | end 83 | 84 | pr_actions.update_pr(repo, pr_number, { title = new_title }, function(success) 85 | if success then 86 | vim.notify('Title updated', vim.log.levels.INFO) 87 | -- Reload PR 88 | M.reload_pr(sidebar_bufnr) 89 | end 90 | end) 91 | end) 92 | end 93 | 94 | -- Edit PR description 95 | function M.edit_pr_description(sidebar_bufnr) 96 | local pr = vim.b[sidebar_bufnr].github_pr 97 | local repo = vim.b[sidebar_bufnr].github_repo 98 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 99 | 100 | input.show_multiline_input('Edit PR Description', pr.body or '', function(new_body) 101 | if not new_body or new_body == pr.body then 102 | return 103 | end 104 | 105 | pr_actions.update_pr(repo, pr_number, { body = new_body }, function(success) 106 | if success then 107 | vim.notify('Description updated', vim.log.levels.INFO) 108 | M.reload_pr(sidebar_bufnr) 109 | end 110 | end) 111 | end, { height = 15, syntax = 'markdown' }) 112 | end 113 | 114 | -- Manage assignees (add/remove) 115 | function M.manage_assignees(sidebar_bufnr) 116 | local pr = vim.b[sidebar_bufnr].github_pr 117 | local repo = vim.b[sidebar_bufnr].github_repo 118 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 119 | 120 | -- Get current assignees 121 | local current_assignees = {} 122 | if pr.assignees then 123 | for _, assignee in ipairs(pr.assignees) do 124 | local data_helper = require('github.utils.data_helper') 125 | local login = data_helper.safe_get_login(assignee) 126 | if login ~= 'unknown' then 127 | table.insert(current_assignees, login) 128 | end 129 | end 130 | end 131 | 132 | vim.ui.select({'Add Assignee', 'Remove Assignee'}, { 133 | prompt = 'Manage Assignees:', 134 | }, function(choice) 135 | if choice == 'Add Assignee' then 136 | -- Fetch collaborators 137 | pr_actions.get_collaborators(repo, function(success, collaborators) 138 | if not success then 139 | vim.notify('Failed to fetch collaborators', vim.log.levels.ERROR) 140 | return 141 | end 142 | 143 | vim.ui.select(collaborators, { 144 | prompt = 'Select assignee to add:', 145 | }, function(assignee) 146 | if assignee then 147 | pr_actions.update_pr(repo, pr_number, { add_assignees = {assignee} }, function(success) 148 | if success then 149 | vim.notify('Assignee added: @' .. assignee, vim.log.levels.INFO) 150 | M.reload_pr(sidebar_bufnr) 151 | end 152 | end) 153 | end 154 | end) 155 | end) 156 | elseif choice == 'Remove Assignee' then 157 | if #current_assignees == 0 then 158 | vim.notify('No assignees to remove', vim.log.levels.WARN) 159 | return 160 | end 161 | 162 | vim.ui.select(current_assignees, { 163 | prompt = 'Select assignee to remove:', 164 | }, function(assignee) 165 | if assignee then 166 | pr_actions.update_pr(repo, pr_number, { remove_assignees = {assignee} }, function(success) 167 | if success then 168 | vim.notify('Assignee removed: @' .. assignee, vim.log.levels.INFO) 169 | M.reload_pr(sidebar_bufnr) 170 | end 171 | end) 172 | end 173 | end) 174 | end 175 | end) 176 | end 177 | 178 | -- Manage labels (add/remove) 179 | function M.manage_labels(sidebar_bufnr) 180 | local pr = vim.b[sidebar_bufnr].github_pr 181 | local repo = vim.b[sidebar_bufnr].github_repo 182 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 183 | 184 | -- Get current labels 185 | local current_labels = {} 186 | if pr.labels then 187 | for _, label in ipairs(pr.labels) do 188 | local data_helper = require('github.utils.data_helper') 189 | local name = data_helper.safe_get_field(label, 'name') 190 | if name then 191 | table.insert(current_labels, name) 192 | end 193 | end 194 | end 195 | 196 | vim.ui.select({'Add Label', 'Remove Label'}, { 197 | prompt = 'Manage Labels:', 198 | }, function(choice) 199 | if choice == 'Add Label' then 200 | pr_actions.get_labels(repo, function(success, labels) 201 | if not success then 202 | vim.notify('Failed to fetch labels', vim.log.levels.ERROR) 203 | return 204 | end 205 | 206 | vim.ui.select(labels, { 207 | prompt = 'Select label to add:', 208 | }, function(label) 209 | if label then 210 | pr_actions.update_pr(repo, pr_number, { add_labels = {label} }, function(success) 211 | if success then 212 | vim.notify('Label added: ' .. label, vim.log.levels.INFO) 213 | M.reload_pr(sidebar_bufnr) 214 | end 215 | end) 216 | end 217 | end) 218 | end) 219 | elseif choice == 'Remove Label' then 220 | if #current_labels == 0 then 221 | vim.notify('No labels to remove', vim.log.levels.WARN) 222 | return 223 | end 224 | 225 | vim.ui.select(current_labels, { 226 | prompt = 'Select label to remove:', 227 | }, function(label) 228 | if label then 229 | pr_actions.update_pr(repo, pr_number, { remove_labels = {label} }, function(success) 230 | if success then 231 | vim.notify('Label removed: ' .. label, vim.log.levels.INFO) 232 | M.reload_pr(sidebar_bufnr) 233 | end 234 | end) 235 | end 236 | end) 237 | end 238 | end) 239 | end 240 | 241 | -- Manage reviewers (request review) 242 | function M.manage_reviewers(sidebar_bufnr) 243 | local repo = vim.b[sidebar_bufnr].github_repo 244 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 245 | 246 | pr_actions.get_collaborators(repo, function(success, collaborators) 247 | if not success then 248 | vim.notify('Failed to fetch collaborators', vim.log.levels.ERROR) 249 | return 250 | end 251 | 252 | local items = vim.tbl_map(function(c) 253 | return { display = '@' .. c, value = c } 254 | end, collaborators) 255 | 256 | form_components.multi_select('Request Reviews From', items, function(reviewers) 257 | if #reviewers > 0 then 258 | pr_actions.request_review(repo, pr_number, reviewers, function(success) 259 | if success then 260 | vim.notify('Review requested from ' .. #reviewers .. ' user(s)', vim.log.levels.INFO) 261 | M.reload_pr(sidebar_bufnr) 262 | end 263 | end) 264 | end 265 | end) 266 | end) 267 | end 268 | 269 | -- Reload PR data 270 | function M.reload_pr(sidebar_bufnr) 271 | local repo = vim.b[sidebar_bufnr].github_repo 272 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 273 | 274 | if not repo or not pr_number then 275 | return 276 | end 277 | 278 | -- Invalidate cache 279 | local cache = require('github.api.cache') 280 | cache.invalidate('pr:' .. repo .. ':' .. pr_number) 281 | 282 | -- Re-open PR viewer 283 | vim.schedule(function() 284 | vim.cmd('tabclose') 285 | require('github').open_pr_detail(pr_number) 286 | end) 287 | end 288 | 289 | return M 290 | -------------------------------------------------------------------------------- /lua/github/api/client.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local async = require('github.utils.async') 3 | local cache = require('github.api.cache') 4 | local config = require('github.config') 5 | 6 | function M.check_gh_available() 7 | return vim.fn.executable(config.config.gh_path) == 1 8 | end 9 | 10 | function M.parse_json(output) 11 | if not output or output == '' then 12 | return nil 13 | end 14 | 15 | local ok, result = pcall(function() 16 | return vim.json.decode(output) 17 | end) 18 | 19 | if not ok then 20 | if config.config.advanced.debug then 21 | vim.notify('JSON parse error: ' .. tostring(result), vim.log.levels.WARN) 22 | end 23 | return nil 24 | end 25 | 26 | return result 27 | end 28 | 29 | function M.run_command(cmd, callback) 30 | if not M.check_gh_available() then 31 | if callback then 32 | callback(false, nil, 'GitHub CLI not found') 33 | end 34 | return 35 | end 36 | 37 | async.run_job(cmd, callback) 38 | end 39 | 40 | function M.cached_command(cmd, cache_key, ttl, callback, is_text) 41 | ttl = ttl or config.config.cache.ttl.list 42 | is_text = is_text or false 43 | 44 | -- Try cache first 45 | local cached_data = cache.get(cache_key) 46 | if cached_data then 47 | if callback then 48 | callback(true, cached_data, nil) 49 | end 50 | return 51 | end 52 | 53 | -- Run command and cache result 54 | M.run_command(cmd, function(success, output, error) 55 | if success then 56 | if is_text then 57 | -- For text output (like diffs), cache as-is 58 | cache.set(cache_key, output, ttl) 59 | if callback then 60 | callback(true, output, nil) 61 | end 62 | else 63 | -- For JSON, parse and cache 64 | local data = M.parse_json(output) 65 | if data then 66 | cache.set(cache_key, data, ttl) 67 | if callback then 68 | callback(true, data, nil) 69 | end 70 | else 71 | if callback then 72 | callback(false, nil, 'Failed to parse JSON') 73 | end 74 | end 75 | end 76 | else 77 | if callback then 78 | callback(false, nil, error) 79 | end 80 | end 81 | end) 82 | end 83 | 84 | -- Get current GitHub user 85 | function M.get_current_user(callback) 86 | local cmd = { 'api', 'user', '--jq', '.login' } 87 | local cache_key = 'current_user' 88 | -- Cache for 1 hour (3600 seconds) since username rarely changes 89 | M.cached_command(cmd, cache_key, 3600, callback, true) 90 | end 91 | 92 | -- Issue commands 93 | function M.list_issues(repo, filters, callback) 94 | if not repo or repo == '' then 95 | if callback then 96 | callback(false, nil, 'Repository not specified') 97 | end 98 | return 99 | end 100 | 101 | local cmd = { 'issue', 'list', '--repo', repo, '--json', 'number,title,state,author,assignees,labels,createdAt,updatedAt,body' } 102 | 103 | if filters.state and filters.state ~= 'all' then 104 | table.insert(cmd, '--state') 105 | table.insert(cmd, filters.state) 106 | end 107 | 108 | if filters.assignee == 'me' then 109 | table.insert(cmd, '--assignee') 110 | table.insert(cmd, '@me') 111 | elseif filters.assignee then 112 | table.insert(cmd, '--assignee') 113 | table.insert(cmd, filters.assignee) 114 | end 115 | 116 | if filters.author then 117 | table.insert(cmd, '--author') 118 | table.insert(cmd, filters.author) 119 | end 120 | 121 | if filters.labels and #filters.labels > 0 then 122 | table.insert(cmd, '--label') 123 | table.insert(cmd, table.concat(filters.labels, ',')) 124 | end 125 | 126 | local cache_key = 'issues:' .. repo .. ':' .. vim.json.encode(filters) 127 | M.cached_command(cmd, cache_key, config.config.cache.ttl.list, callback) 128 | end 129 | 130 | function M.get_issue(repo, number, callback) 131 | local cmd = { 'issue', 'view', tostring(number), '--repo', repo, '--json', 'number,title,state,body,author,assignees,labels,milestone,comments,createdAt,updatedAt' } 132 | local cache_key = 'issue:' .. repo .. ':' .. number 133 | M.cached_command(cmd, cache_key, config.config.cache.ttl.detail, callback) 134 | end 135 | 136 | function M.create_issue(repo, title, body, callback) 137 | local cmd = { 'issue', 'create', '--repo', repo, '--title', title, '--body', body, '--json', 'number,title,state' } 138 | M.run_command(cmd, function(success, output, error) 139 | if success then 140 | cache.invalidate('issues:' .. repo) 141 | local data = M.parse_json(output) 142 | if callback then 143 | callback(true, data, nil) 144 | end 145 | else 146 | if callback then 147 | callback(false, nil, error) 148 | end 149 | end 150 | end) 151 | end 152 | 153 | function M.add_issue_comment(repo, number, body, callback) 154 | local cmd = { 'issue', 'comment', tostring(number), '--repo', repo, '--body', body } 155 | M.run_command(cmd, function(success, output, error) 156 | if success then 157 | cache.invalidate('issue:' .. repo .. ':' .. number) 158 | cache.invalidate('issues:' .. repo) 159 | if callback then 160 | callback(true, nil, nil) 161 | end 162 | else 163 | if callback then 164 | callback(false, nil, error) 165 | end 166 | end 167 | end) 168 | end 169 | 170 | -- PR commands 171 | function M.list_prs(repo, filters, callback) 172 | local cmd = { 'pr', 'list', '--repo', repo, '--json', 'number,title,state,author,assignees,reviewRequests,labels,createdAt,updatedAt,isDraft,reviewDecision' } 173 | 174 | -- Always fetch all PRs to ensure drafts and closed PRs are included 175 | -- We'll filter by state in filter_manager.lua instead 176 | table.insert(cmd, '--state') 177 | table.insert(cmd, 'all') 178 | 179 | if filters.assignee == 'me' then 180 | table.insert(cmd, '--assignee') 181 | table.insert(cmd, '@me') 182 | elseif filters.assignee then 183 | table.insert(cmd, '--assignee') 184 | table.insert(cmd, filters.assignee) 185 | end 186 | 187 | if filters.reviewer == 'me' then 188 | table.insert(cmd, '--reviewer') 189 | table.insert(cmd, '@me') 190 | elseif filters.reviewer then 191 | table.insert(cmd, '--reviewer') 192 | table.insert(cmd, filters.reviewer) 193 | end 194 | 195 | if filters.author then 196 | table.insert(cmd, '--author') 197 | table.insert(cmd, filters.author) 198 | end 199 | 200 | if filters.labels and #filters.labels > 0 then 201 | table.insert(cmd, '--label') 202 | table.insert(cmd, table.concat(filters.labels, ',')) 203 | end 204 | 205 | local cache_key = 'prs:' .. repo .. ':' .. vim.json.encode(filters) 206 | M.cached_command(cmd, cache_key, config.config.cache.ttl.list, callback) 207 | end 208 | 209 | function M.get_pr(repo, number, callback) 210 | local cmd = { 'pr', 'view', tostring(number), '--repo', repo, '--json', 'number,title,state,body,author,assignees,reviewRequests,reviews,reviewDecision,labels,statusCheckRollup,isDraft,createdAt,updatedAt,comments,commits,headRefName,baseRefName,mergeable,mergeStateStatus,mergedAt,mergedBy' } 211 | local cache_key = 'pr:' .. repo .. ':' .. number 212 | M.cached_command(cmd, cache_key, config.config.cache.ttl.detail, callback) 213 | end 214 | 215 | function M.get_pr_diff(repo, number, callback) 216 | local cmd = { 'pr', 'diff', tostring(number), '--repo', repo } 217 | local cache_key = 'pr_diff:' .. repo .. ':' .. number 218 | M.cached_command(cmd, cache_key, config.config.cache.ttl.diff, function(success, output, error) 219 | if success then 220 | if callback then 221 | callback(true, output, nil) 222 | end 223 | else 224 | if callback then 225 | callback(false, nil, error) 226 | end 227 | end 228 | end, true) -- is_text = true 229 | end 230 | 231 | function M.get_pr_files(repo, number, callback) 232 | local cmd = { 'api', 'repos/' .. repo .. '/pulls/' .. tostring(number) .. '/files', '--jq', '.[] | {path: .filename, additions: .additions, deletions: .deletions, changes: .changes, status: .status, patch: .patch}' } 233 | local cache_key = 'pr_files:' .. repo .. ':' .. number 234 | M.cached_command(cmd, cache_key, config.config.cache.ttl.diff, callback) 235 | end 236 | 237 | function M.add_pr_comment(repo, number, body, line, callback) 238 | local cmd = { 'pr', 'comment', tostring(number), '--repo', repo, '--body', body } 239 | if line then 240 | table.insert(cmd, '--line') 241 | table.insert(cmd, tostring(line)) 242 | end 243 | M.run_command(cmd, function(success, output, error) 244 | if success then 245 | cache.invalidate('pr:' .. repo .. ':' .. number) 246 | cache.invalidate('prs:' .. repo) 247 | if callback then 248 | callback(true, nil, nil) 249 | end 250 | else 251 | if callback then 252 | callback(false, nil, error) 253 | end 254 | end 255 | end) 256 | end 257 | 258 | function M.review_pr(repo, number, state, body, callback) 259 | local cmd = { 'pr', 'review', tostring(number), '--repo', repo } 260 | 261 | if state == 'approve' then 262 | table.insert(cmd, '--approve') 263 | elseif state == 'request-changes' then 264 | table.insert(cmd, '--request-changes') 265 | else 266 | table.insert(cmd, '--comment') 267 | end 268 | 269 | if body then 270 | table.insert(cmd, '--body') 271 | table.insert(cmd, body) 272 | end 273 | 274 | M.run_command(cmd, function(success, output, error) 275 | if success then 276 | cache.invalidate('pr:' .. repo .. ':' .. number) 277 | cache.invalidate('prs:' .. repo) 278 | cache.invalidate('pr_review_comments:' .. repo .. ':' .. number) 279 | if callback then 280 | callback(true, nil, nil) 281 | end 282 | else 283 | if callback then 284 | callback(false, nil, error) 285 | end 286 | end 287 | end) 288 | end 289 | 290 | -- Get PR review comments (inline code comments) 291 | function M.get_pr_review_comments(repo, number, callback) 292 | local cmd = { 'api', 'repos/' .. repo .. '/pulls/' .. tostring(number) .. '/comments' } 293 | local cache_key = 'pr_review_comments:' .. repo .. ':' .. number 294 | M.cached_command(cmd, cache_key, config.config.cache.ttl.detail, callback) 295 | end 296 | 297 | -- Start a pending review 298 | function M.start_review(repo, number, callback) 299 | -- This creates a pending review that can have multiple comments 300 | -- GitHub API: POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews with body {event: "PENDING"} 301 | local cmd = { 'api', 'repos/' .. repo .. '/pulls/' .. tostring(number) .. '/reviews', '--method', 'POST', '--raw-field', 'event=PENDING' } 302 | M.run_command(cmd, function(success, output, error) 303 | if success then 304 | local data = M.parse_json(output) 305 | if callback then 306 | callback(true, data, nil) 307 | end 308 | else 309 | if callback then 310 | callback(false, nil, error) 311 | end 312 | end 313 | end) 314 | end 315 | 316 | -- Submit a pending review 317 | function M.submit_review(repo, number, review_id, event, body, callback) 318 | -- event can be: APPROVE, REQUEST_CHANGES, COMMENT 319 | local cmd = { 'api', 'repos/' .. repo .. '/pulls/' .. tostring(number) .. '/reviews/' .. tostring(review_id) .. '/events', '--method', 'POST', '--field', 'event=' .. event } 320 | if body and body ~= '' then 321 | table.insert(cmd, '--field') 322 | table.insert(cmd, 'body=' .. body) 323 | end 324 | 325 | M.run_command(cmd, function(success, output, error) 326 | if success then 327 | cache.invalidate('pr:' .. repo .. ':' .. number) 328 | cache.invalidate('prs:' .. repo) 329 | cache.invalidate('pr_review_comments:' .. repo .. ':' .. number) 330 | if callback then 331 | callback(true, nil, nil) 332 | end 333 | else 334 | if callback then 335 | callback(false, nil, error) 336 | end 337 | end 338 | end) 339 | end 340 | 341 | -- Add a comment to a pending review 342 | function M.add_review_comment(repo, number, review_id, body, path, line, callback) 343 | local cmd = { 'api', 'repos/' .. repo .. '/pulls/' .. tostring(number) .. '/reviews/' .. tostring(review_id) .. '/comments', '--method', 'POST', '--field', 'body=' .. body, '--field', 'path=' .. path, '--field', 'line=' .. tostring(line) } 344 | 345 | M.run_command(cmd, function(success, output, error) 346 | if success then 347 | cache.invalidate('pr_review_comments:' .. repo .. ':' .. number) 348 | if callback then 349 | callback(true, nil, nil) 350 | end 351 | else 352 | if callback then 353 | callback(false, nil, error) 354 | end 355 | end 356 | end) 357 | end 358 | 359 | return M 360 | -------------------------------------------------------------------------------- /lua/github/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require('github.config') 3 | local client = require('github.api.client') 4 | local buffer = require('github.ui.buffer') 5 | local window = require('github.ui.window') 6 | local list_view = require('github.ui.list_view') 7 | local issue_view = require('github.ui.issue_view') 8 | local pr_view = require('github.ui.pr_view') 9 | local diff_view = require('github.ui.diff_view') 10 | local filter_manager = require('github.filters.filter_manager') 11 | local issue_actions = require('github.actions.issue_actions') 12 | local pr_actions = require('github.actions.pr_actions') 13 | local review_actions = require('github.actions.review_actions') 14 | local keymaps = require('github.keymaps') 15 | local commands = require('github.commands') 16 | 17 | function M.setup(opts) 18 | config.setup(opts) 19 | commands.setup_commands() 20 | M.setup_global_keymaps() 21 | end 22 | 23 | function M.setup_global_keymaps() 24 | local keymaps_config = config.config.keymaps 25 | 26 | if keymaps_config.open then 27 | vim.keymap.set('n', keymaps_config.open, function() 28 | M.open() 29 | end, { silent = true }) 30 | end 31 | 32 | if keymaps_config.issues then 33 | vim.keymap.set('n', keymaps_config.issues, function() 34 | M.open_issues() 35 | end, { silent = true }) 36 | end 37 | 38 | if keymaps_config.prs then 39 | vim.keymap.set('n', keymaps_config.prs, function() 40 | M.open_prs() 41 | end, { silent = true }) 42 | end 43 | 44 | if keymaps_config.assigned then 45 | vim.keymap.set('n', keymaps_config.assigned, function() 46 | M.open_assigned() 47 | end, { silent = true }) 48 | end 49 | 50 | if keymaps_config.refresh then 51 | vim.keymap.set('n', keymaps_config.refresh, function() 52 | M.refresh() 53 | end, { silent = true }) 54 | end 55 | end 56 | 57 | function M.get_repo() 58 | local repo = config.get_repo() 59 | if not repo then 60 | vim.notify('Could not detect repository. Please set repo in config.', vim.log.levels.ERROR) 61 | return nil 62 | end 63 | return repo 64 | end 65 | 66 | function M.open() 67 | M.open_issues() 68 | end 69 | 70 | function M.open_issues() 71 | local repo = M.get_repo() 72 | if not repo then 73 | return 74 | end 75 | 76 | local filters = filter_manager.get_filters('issues') 77 | filters.type = 'issues' 78 | 79 | local winid = window.open_list_window() 80 | local bufnr = buffer.create_buffer(buffer.BUFFER_TYPES.LIST, { type = 'issues' }) 81 | vim.api.nvim_win_set_buf(winid, bufnr) 82 | buffer.setup_window_options(winid) 83 | keymaps.setup_keymaps(bufnr, buffer.BUFFER_TYPES.LIST) 84 | 85 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'Loading issues...' }) 86 | 87 | client.list_issues(repo, filters, function(success, data, error) 88 | -- Schedule buffer updates to avoid fast event context issues 89 | vim.schedule(function() 90 | if success and data then 91 | local filtered = filter_manager.apply_filters(data, filters, 'issues') 92 | list_view.render_list(bufnr, filtered, 1, filters) 93 | else 94 | local error_msg = error or 'unknown error' 95 | local error_lines = vim.split(error_msg, '\n') 96 | local display_lines = { 'Error loading issues: ' .. error_lines[1] } 97 | for i = 2, #error_lines do 98 | table.insert(display_lines, error_lines[i]) 99 | end 100 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines) 101 | end 102 | end) 103 | end) 104 | end 105 | 106 | function M.open_prs() 107 | local repo = M.get_repo() 108 | if not repo then 109 | return 110 | end 111 | 112 | local filters = filter_manager.get_filters('prs') 113 | filters.type = 'prs' 114 | 115 | local winid = window.open_list_window() 116 | local bufnr = buffer.create_buffer(buffer.BUFFER_TYPES.LIST, { type = 'prs' }) 117 | vim.api.nvim_win_set_buf(winid, bufnr) 118 | buffer.setup_window_options(winid) 119 | keymaps.setup_keymaps(bufnr, buffer.BUFFER_TYPES.LIST) 120 | 121 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'Loading PRs...' }) 122 | 123 | client.list_prs(repo, filters, function(success, data, error) 124 | vim.schedule(function() 125 | if success and data then 126 | local filtered = filter_manager.apply_filters(data, filters, 'prs') 127 | list_view.render_list(bufnr, filtered, 1, filters) 128 | else 129 | local error_msg = error or 'unknown error' 130 | local error_lines = vim.split(error_msg, '\n') 131 | local display_lines = { 'Error loading PRs: ' .. error_lines[1] } 132 | for i = 2, #error_lines do 133 | table.insert(display_lines, error_lines[i]) 134 | end 135 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines) 136 | end 137 | end) 138 | end) 139 | end 140 | 141 | function M.open_assigned() 142 | local repo = M.get_repo() 143 | if not repo then 144 | return 145 | end 146 | 147 | -- Set filters for both issues and PRs to show only items assigned to me 148 | filter_manager.set_filters('issues', { assignee = 'me' }) 149 | filter_manager.set_filters('prs', { assignee = 'me' }) 150 | 151 | -- Open issues assigned to me 152 | M.open_issues() 153 | 154 | -- Open PRs assigned to me in vertical split 155 | vim.cmd('vsplit') 156 | M.open_prs() 157 | end 158 | 159 | function M.open_issues_closed() 160 | local repo = M.get_repo() 161 | if not repo then 162 | return 163 | end 164 | 165 | filter_manager.set_filters('issues', { state = 'closed' }) 166 | M.open_issues() 167 | end 168 | 169 | function M.open_prs_closed() 170 | local repo = M.get_repo() 171 | if not repo then 172 | return 173 | end 174 | 175 | filter_manager.set_filters('prs', { state = 'closed' }) 176 | M.open_prs() 177 | end 178 | 179 | function M.open_issues_all() 180 | local repo = M.get_repo() 181 | if not repo then 182 | return 183 | end 184 | 185 | filter_manager.set_filters('issues', { state = 'all' }) 186 | M.open_issues() 187 | end 188 | 189 | function M.open_prs_all() 190 | local repo = M.get_repo() 191 | if not repo then 192 | return 193 | end 194 | 195 | filter_manager.set_filters('prs', { state = 'all' }) 196 | M.open_prs() 197 | end 198 | 199 | function M.open_issue_detail(number) 200 | local repo = M.get_repo() 201 | if not repo then 202 | return 203 | end 204 | 205 | local winid = window.open_detail_window() 206 | local bufnr = buffer.create_buffer(buffer.BUFFER_TYPES.ISSUE) 207 | vim.api.nvim_win_set_buf(winid, bufnr) 208 | buffer.setup_window_options(winid) 209 | keymaps.setup_keymaps(bufnr, buffer.BUFFER_TYPES.ISSUE) 210 | 211 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'Loading issue...' }) 212 | 213 | client.get_issue(repo, number, function(success, data, error) 214 | vim.schedule(function() 215 | if success and data then 216 | issue_view.render_issue(bufnr, data) 217 | vim.b[bufnr].github_repo = repo 218 | else 219 | local error_msg = error or 'unknown error' 220 | local error_lines = vim.split(error_msg, '\n') 221 | local display_lines = { 'Error loading issue: ' .. error_lines[1] } 222 | for i = 2, #error_lines do 223 | table.insert(display_lines, error_lines[i]) 224 | end 225 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines) 226 | end 227 | end) 228 | end) 229 | end 230 | 231 | function M.open_pr_detail(number) 232 | local repo = M.get_repo() 233 | if not repo then 234 | return 235 | end 236 | 237 | -- Use comprehensive PR viewer with split layout 238 | local pr_viewer = require('github.ui.pr_viewer') 239 | pr_viewer.open_pr_viewer(repo, number) 240 | end 241 | 242 | function M.open_pr_diff(number, file_path) 243 | local repo = M.get_repo() 244 | if not repo then 245 | return 246 | end 247 | 248 | local winid = window.open_diff_window() 249 | local bufnr = buffer.create_buffer(buffer.BUFFER_TYPES.DIFF) 250 | vim.api.nvim_win_set_buf(winid, bufnr) 251 | buffer.setup_window_options(winid) 252 | keymaps.setup_keymaps(bufnr, buffer.BUFFER_TYPES.DIFF) 253 | 254 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'Loading diff...' }) 255 | 256 | client.get_pr_diff(repo, number, function(success, diff_text, error) 257 | vim.schedule(function() 258 | if success and diff_text then 259 | diff_view.render_diff(bufnr, diff_text, file_path) 260 | vim.b[bufnr].github_repo = repo 261 | vim.b[bufnr].github_pr_number = number 262 | 263 | -- Also get PR data for actions 264 | client.get_pr(repo, number, function(success, pr_data) 265 | vim.schedule(function() 266 | if success then 267 | vim.b[bufnr].github_pr = pr_data 268 | end 269 | end) 270 | end) 271 | else 272 | local error_msg = error or 'unknown error' 273 | local error_lines = vim.split(error_msg, '\n') 274 | local display_lines = { 'Error loading diff: ' .. error_lines[1] } 275 | for i = 2, #error_lines do 276 | table.insert(display_lines, error_lines[i]) 277 | end 278 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines) 279 | end 280 | end) 281 | end) 282 | end 283 | 284 | function M.refresh() 285 | local bufnr = vim.api.nvim_get_current_buf() 286 | if not buffer.is_github_buffer(bufnr) then 287 | return 288 | end 289 | 290 | local buf_type = buffer.get_buffer_type(bufnr) 291 | 292 | if buf_type == buffer.BUFFER_TYPES.LIST then 293 | local filters = vim.b[bufnr].github_filters 294 | if filters and filters.type == 'prs' then 295 | M.open_prs() 296 | else 297 | M.open_issues() 298 | end 299 | elseif buf_type == buffer.BUFFER_TYPES.ISSUE then 300 | local issue = vim.b[bufnr].github_issue 301 | if issue then 302 | M.open_issue_detail(issue.number) 303 | end 304 | elseif buf_type == buffer.BUFFER_TYPES.PR then 305 | local pr = vim.b[bufnr].github_pr 306 | if pr then 307 | M.open_pr_detail(pr.number) 308 | end 309 | end 310 | end 311 | 312 | function M.create_issue(title, body) 313 | local repo = M.get_repo() 314 | if not repo then 315 | return 316 | end 317 | 318 | issue_actions.create(repo, title, body or '', function(success, data) 319 | vim.schedule(function() 320 | if success then 321 | M.open_issue_detail(data.number) 322 | end 323 | end) 324 | end) 325 | end 326 | 327 | function M.create_pr(title, body, head, base) 328 | local repo = M.get_repo() 329 | if not repo then 330 | return 331 | end 332 | 333 | pr_actions.create(repo, title, body or '', head, base or 'main', function(success, data) 334 | vim.schedule(function() 335 | if success then 336 | M.open_pr_detail(data.number) 337 | end 338 | end) 339 | end) 340 | end 341 | 342 | function M.create_pr_interactive() 343 | local repo = M.get_repo() 344 | if not repo then 345 | return 346 | end 347 | 348 | local pr_creator = require('github.feature.pr_creator') 349 | pr_creator.open_pr_creator(repo) 350 | end 351 | 352 | function M.add_issue_comment(number) 353 | local repo = M.get_repo() 354 | if not repo then 355 | return 356 | end 357 | 358 | local body = vim.fn.input('Comment: ') 359 | if body and body ~= '' then 360 | issue_actions.add_comment(repo, number, body, function(success) 361 | vim.schedule(function() 362 | if success then 363 | M.open_issue_detail(number) 364 | end 365 | end) 366 | end) 367 | end 368 | end 369 | 370 | function M.add_pr_comment(number, line) 371 | local repo = M.get_repo() 372 | if not repo then 373 | return 374 | end 375 | 376 | local body = vim.fn.input('Comment: ') 377 | if body and body ~= '' then 378 | pr_actions.add_comment(repo, number, body, line, function(success) 379 | vim.schedule(function() 380 | if success then 381 | M.open_pr_detail(number) 382 | end 383 | end) 384 | end) 385 | end 386 | end 387 | 388 | function M.approve_pr(number) 389 | local repo = M.get_repo() 390 | if not repo then 391 | return 392 | end 393 | 394 | review_actions.approve(repo, number, nil, function(success) 395 | vim.schedule(function() 396 | if success then 397 | M.open_pr_detail(number) 398 | end 399 | end) 400 | end) 401 | end 402 | 403 | function M.request_changes_pr(number) 404 | local repo = M.get_repo() 405 | if not repo then 406 | return 407 | end 408 | 409 | local body = vim.fn.input('Reason for changes (optional): ') 410 | review_actions.request_changes(repo, number, body or '', function(success) 411 | vim.schedule(function() 412 | if success then 413 | M.open_pr_detail(number) 414 | end 415 | end) 416 | end) 417 | end 418 | 419 | -- Search functionality 420 | function M.search_prs() 421 | local repo = M.get_repo() 422 | if not repo then 423 | return 424 | end 425 | 426 | local search = require('github.ui.search') 427 | search.show_pr_search(repo, function(pr) 428 | if pr then 429 | M.open_pr_detail(pr.number) 430 | end 431 | end) 432 | end 433 | 434 | function M.search_issues() 435 | local repo = M.get_repo() 436 | if not repo then 437 | return 438 | end 439 | 440 | local search = require('github.ui.search') 441 | search.show_issue_search(repo, function(issue) 442 | if issue then 443 | M.open_issue_detail(issue.number) 444 | end 445 | end) 446 | end 447 | 448 | return M 449 | -------------------------------------------------------------------------------- /lua/github/feature/pr_creator.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local pr_actions = require('github.actions.pr_actions') 3 | local client = require('github.api.client') 4 | local input = require('github.ui.input') 5 | local floating = require('github.ui.floating') 6 | local buffer = require('github.ui.buffer') 7 | local form_components = require('github.feature.form_components') 8 | 9 | -- Main entry point: Open PR creation form 10 | function M.open_pr_creator(repo) 11 | -- Create new tab with split layout 12 | vim.cmd('tabnew') 13 | vim.cmd('vsplit') 14 | 15 | -- Left: Form buffer (40%) 16 | vim.cmd('wincmd h') 17 | local form_winid = vim.api.nvim_get_current_win() 18 | 19 | -- Right: Preview buffer (60%) 20 | vim.cmd('wincmd l') 21 | local preview_winid = vim.api.nvim_get_current_win() 22 | 23 | -- Set widths 24 | local total_width = vim.o.columns 25 | local form_width = math.floor(total_width * 0.4) 26 | vim.api.nvim_win_set_width(form_winid, form_width) 27 | 28 | -- Create buffers 29 | local form_bufnr = vim.api.nvim_create_buf(false, true) 30 | local preview_bufnr = vim.api.nvim_create_buf(false, true) 31 | 32 | -- Setup form buffer 33 | pcall(vim.api.nvim_buf_set_name, form_bufnr, 'github_pr_creator_form') 34 | vim.bo[form_bufnr].buftype = 'nofile' 35 | vim.bo[form_bufnr].swapfile = false 36 | vim.bo[form_bufnr].bufhidden = 'wipe' 37 | vim.bo[form_bufnr].modifiable = true 38 | vim.bo[form_bufnr].filetype = 'markdown' 39 | 40 | -- Setup preview buffer 41 | pcall(vim.api.nvim_buf_set_name, preview_bufnr, 'github_pr_creator_preview') 42 | vim.bo[preview_bufnr].buftype = 'nofile' 43 | vim.bo[preview_bufnr].swapfile = false 44 | vim.bo[preview_bufnr].bufhidden = 'wipe' 45 | vim.bo[preview_bufnr].modifiable = true 46 | vim.bo[preview_bufnr].filetype = 'markdown' 47 | 48 | -- Set buffers in windows 49 | vim.api.nvim_win_set_buf(form_winid, form_bufnr) 50 | vim.api.nvim_win_set_buf(preview_winid, preview_bufnr) 51 | 52 | buffer.setup_window_options(form_winid) 53 | buffer.setup_window_options(preview_winid) 54 | 55 | -- Initialize state 56 | vim.b[form_bufnr].github_pr_creator = { 57 | repo = repo, 58 | title = '', 59 | body = '', 60 | head = '', 61 | base = 'main', 62 | draft = false, 63 | reviewers = {}, 64 | labels = {}, 65 | form_winid = form_winid, 66 | preview_winid = preview_winid, 67 | preview_bufnr = preview_bufnr, 68 | } 69 | 70 | -- Show loading 71 | vim.api.nvim_buf_set_lines(form_bufnr, 0, -1, false, { 'Loading PR creator...' }) 72 | vim.api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, { 'Preview will appear here...' }) 73 | 74 | -- Fetch initial data (current branch, template, branches, collaborators, labels) 75 | M.load_initial_data(form_bufnr, function(data) 76 | vim.schedule(function() 77 | if not vim.api.nvim_buf_is_valid(form_bufnr) then 78 | return 79 | end 80 | 81 | -- Update state with loaded data 82 | local state = vim.b[form_bufnr].github_pr_creator 83 | state.head = data.current_branch 84 | state.body = data.template 85 | state.available_branches = data.branches 86 | state.available_reviewers = data.collaborators 87 | state.available_labels = data.labels 88 | 89 | -- Render form 90 | M.render_form(form_bufnr) 91 | 92 | -- Render initial preview 93 | M.update_preview(form_bufnr) 94 | 95 | -- Setup keymaps 96 | M.setup_keymaps(form_bufnr) 97 | 98 | -- Focus on form 99 | vim.api.nvim_set_current_win(form_winid) 100 | end) 101 | end) 102 | end 103 | 104 | -- Load initial data asynchronously 105 | function M.load_initial_data(form_bufnr, callback) 106 | local state = vim.b[form_bufnr].github_pr_creator 107 | local repo = state.repo 108 | 109 | local data = { 110 | current_branch = '', 111 | template = '', 112 | branches = {}, 113 | collaborators = {}, 114 | labels = {}, 115 | } 116 | 117 | local completed = 0 118 | local total = 5 119 | 120 | local function check_complete() 121 | completed = completed + 1 122 | if completed == total then 123 | callback(data) 124 | end 125 | end 126 | 127 | -- Get current branch 128 | pr_actions.get_current_branch(function(branch) 129 | data.current_branch = branch 130 | check_complete() 131 | end) 132 | 133 | -- Load template 134 | pr_actions.load_pr_template(function(template) 135 | data.template = template 136 | check_complete() 137 | end) 138 | 139 | -- Get branches 140 | pr_actions.get_branches(repo, function(success, branches) 141 | data.branches = success and branches or {} 142 | check_complete() 143 | end) 144 | 145 | -- Get collaborators 146 | pr_actions.get_collaborators(repo, function(success, collaborators) 147 | data.collaborators = success and collaborators or {} 148 | check_complete() 149 | end) 150 | 151 | -- Get labels 152 | pr_actions.get_labels(repo, function(success, labels) 153 | data.labels = success and labels or {} 154 | check_complete() 155 | end) 156 | end 157 | 158 | -- Render form with all fields 159 | function M.render_form(bufnr) 160 | local state = vim.b[bufnr].github_pr_creator 161 | 162 | local lines = {} 163 | local highlights = {} 164 | 165 | -- Header 166 | table.insert(lines, '━━━ Create Pull Request ━━━') 167 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 168 | table.insert(lines, '') 169 | table.insert(lines, string.format('Repository: %s', state.repo)) 170 | table.insert(lines, '') 171 | 172 | -- Instructions 173 | table.insert(lines, '📝 Fill in the fields below:') 174 | table.insert(lines, '') 175 | 176 | -- Title 177 | table.insert(lines, '━━━ Title ━━━') 178 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 179 | if state.title == '' then 180 | table.insert(lines, ' [Press to edit]') 181 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 182 | else 183 | table.insert(lines, ' ' .. state.title) 184 | end 185 | table.insert(lines, '') 186 | 187 | -- Description 188 | table.insert(lines, '━━━ Description ━━━') 189 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 190 | if state.body == '' then 191 | table.insert(lines, ' [Press to edit]') 192 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 193 | else 194 | local body_preview = vim.split(state.body, '\n')[1] or '' 195 | if #body_preview > 60 then 196 | body_preview = body_preview:sub(1, 57) .. '...' 197 | end 198 | table.insert(lines, ' ' .. body_preview) 199 | local line_count = #vim.split(state.body, '\n') 200 | if line_count > 1 then 201 | table.insert(lines, string.format(' (%d more lines)', line_count - 1)) 202 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 203 | end 204 | end 205 | table.insert(lines, '') 206 | 207 | -- Branches 208 | table.insert(lines, '━━━ Branches ━━━') 209 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 210 | table.insert(lines, string.format(' Head: %s [Press to change]', state.head)) 211 | table.insert(lines, string.format(' Base: %s [Press to change]', state.base)) 212 | table.insert(lines, '') 213 | 214 | -- Draft toggle 215 | table.insert(lines, '━━━ Options ━━━') 216 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 217 | local draft_icon = state.draft and '[x]' or '[ ]' 218 | table.insert(lines, string.format(' %s Draft PR [Press to toggle]', draft_icon)) 219 | table.insert(lines, '') 220 | 221 | -- Reviewers 222 | table.insert(lines, '━━━ Reviewers ━━━') 223 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 224 | if #state.reviewers == 0 then 225 | table.insert(lines, ' [No reviewers - Press to add]') 226 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 227 | else 228 | for _, reviewer in ipairs(state.reviewers) do 229 | table.insert(lines, ' 👤 @' .. reviewer) 230 | end 231 | table.insert(lines, ' [Press to modify]') 232 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 233 | end 234 | table.insert(lines, '') 235 | 236 | -- Labels 237 | table.insert(lines, '━━━ Labels ━━━') 238 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 239 | if #state.labels == 0 then 240 | table.insert(lines, ' [No labels - Press to add]') 241 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 242 | else 243 | for _, label in ipairs(state.labels) do 244 | table.insert(lines, ' 🏷 ' .. label) 245 | end 246 | table.insert(lines, ' [Press to modify]') 247 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 248 | end 249 | table.insert(lines, '') 250 | 251 | -- Actions 252 | table.insert(lines, '━━━ Actions ━━━') 253 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 254 | table.insert(lines, ' - Submit PR') 255 | table.insert(lines, ' - Switch to preview') 256 | table.insert(lines, ' q - Cancel and close') 257 | 258 | vim.bo[bufnr].modifiable = true 259 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 260 | vim.bo[bufnr].modifiable = false 261 | 262 | -- Apply highlights 263 | local ns = vim.api.nvim_create_namespace('github_pr_creator_form') 264 | vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) 265 | for _, hl in ipairs(highlights) do 266 | vim.api.nvim_buf_add_highlight(bufnr, ns, hl[4], hl[1], hl[2], hl[3]) 267 | end 268 | end 269 | 270 | -- Update live preview 271 | function M.update_preview(bufnr) 272 | local state = vim.b[bufnr].github_pr_creator 273 | local preview_bufnr = state.preview_bufnr 274 | 275 | if not vim.api.nvim_buf_is_valid(preview_bufnr) then 276 | return 277 | end 278 | 279 | local lines = {} 280 | 281 | -- Header 282 | table.insert(lines, '# ' .. (state.title ~= '' and state.title or 'PR Title')) 283 | table.insert(lines, '') 284 | table.insert(lines, string.format('**%s** → **%s**', state.head, state.base)) 285 | table.insert(lines, '') 286 | 287 | if state.draft then 288 | table.insert(lines, '> **Draft PR**') 289 | table.insert(lines, '') 290 | end 291 | 292 | -- Body 293 | if state.body ~= '' then 294 | local body_lines = vim.split(state.body, '\n') 295 | for _, line in ipairs(body_lines) do 296 | table.insert(lines, line) 297 | end 298 | else 299 | table.insert(lines, '_No description provided._') 300 | end 301 | 302 | table.insert(lines, '') 303 | table.insert(lines, '---') 304 | table.insert(lines, '') 305 | 306 | -- Metadata 307 | if #state.reviewers > 0 then 308 | table.insert(lines, '**Reviewers:**') 309 | for _, reviewer in ipairs(state.reviewers) do 310 | table.insert(lines, '- @' .. reviewer) 311 | end 312 | table.insert(lines, '') 313 | end 314 | 315 | if #state.labels > 0 then 316 | table.insert(lines, '**Labels:**') 317 | for _, label in ipairs(state.labels) do 318 | table.insert(lines, '- `' .. label .. '`') 319 | end 320 | table.insert(lines, '') 321 | end 322 | 323 | vim.bo[preview_bufnr].modifiable = true 324 | vim.api.nvim_buf_set_lines(preview_bufnr, 0, -1, false, lines) 325 | vim.bo[preview_bufnr].modifiable = false 326 | end 327 | 328 | -- Setup interactive keymaps 329 | function M.setup_keymaps(bufnr) 330 | local state = vim.b[bufnr].github_pr_creator 331 | 332 | -- Helper to map keys 333 | local function map(key, fn) 334 | vim.keymap.set('n', key, fn, { buffer = bufnr, silent = true }) 335 | end 336 | 337 | -- Edit title 338 | map('', function() 339 | local line = vim.fn.line('.') 340 | local content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 341 | local section = M.detect_section(content, line) 342 | 343 | if section == 'title' then 344 | M.edit_title(bufnr) 345 | elseif section == 'description' then 346 | M.edit_description(bufnr) 347 | elseif section == 'head' then 348 | M.select_head_branch(bufnr) 349 | elseif section == 'base' then 350 | M.select_base_branch(bufnr) 351 | elseif section == 'draft' then 352 | M.toggle_draft(bufnr) 353 | elseif section == 'reviewers' then 354 | M.select_reviewers(bufnr) 355 | elseif section == 'labels' then 356 | M.select_labels(bufnr) 357 | end 358 | end) 359 | 360 | -- Submit PR 361 | map('', function() 362 | M.submit_pr(bufnr) 363 | end) 364 | 365 | -- Switch to preview 366 | map('', function() 367 | local wins = vim.fn.win_findbuf(state.preview_bufnr) 368 | if #wins > 0 then 369 | vim.api.nvim_set_current_win(wins[1]) 370 | end 371 | end) 372 | 373 | -- Close/cancel 374 | map('q', function() 375 | vim.cmd('tabclose') 376 | end) 377 | 378 | -- Preview keymaps 379 | local preview_bufnr = state.preview_bufnr 380 | vim.keymap.set('n', '', function() 381 | local wins = vim.fn.win_findbuf(bufnr) 382 | if #wins > 0 then 383 | vim.api.nvim_set_current_win(wins[1]) 384 | end 385 | end, { buffer = preview_bufnr, silent = true }) 386 | 387 | vim.keymap.set('n', 'q', function() 388 | vim.cmd('tabclose') 389 | end, { buffer = preview_bufnr, silent = true }) 390 | end 391 | 392 | -- Detect which section the cursor is in 393 | function M.detect_section(lines, cursor_line) 394 | for i = cursor_line, 1, -1 do 395 | local line = lines[i] 396 | if line:match('^━━━ Title') then 397 | return 'title' 398 | elseif line:match('^━━━ Description') then 399 | return 'description' 400 | elseif line:match('Head:') then 401 | return 'head' 402 | elseif line:match('Base:') then 403 | return 'base' 404 | elseif line:match('%[.%] Draft') then 405 | return 'draft' 406 | elseif line:match('^━━━ Reviewers') then 407 | return 'reviewers' 408 | elseif line:match('^━━━ Labels') then 409 | return 'labels' 410 | end 411 | end 412 | return nil 413 | end 414 | 415 | -- Field editing functions 416 | function M.edit_title(bufnr) 417 | local state = vim.b[bufnr].github_pr_creator 418 | vim.ui.input({ 419 | prompt = 'PR Title: ', 420 | default = state.title, 421 | }, function(value) 422 | if value then 423 | state.title = value 424 | M.render_form(bufnr) 425 | M.update_preview(bufnr) 426 | end 427 | end) 428 | end 429 | 430 | function M.edit_description(bufnr) 431 | local state = vim.b[bufnr].github_pr_creator 432 | input.show_multiline_input('Edit PR Description', state.body, function(value) 433 | if value then 434 | state.body = value 435 | M.render_form(bufnr) 436 | M.update_preview(bufnr) 437 | end 438 | end, { height = 15, syntax = 'markdown' }) 439 | end 440 | 441 | function M.select_head_branch(bufnr) 442 | local state = vim.b[bufnr].github_pr_creator 443 | if #state.available_branches == 0 then 444 | vim.notify('No branches available', vim.log.levels.WARN) 445 | return 446 | end 447 | form_components.single_select('Select Head Branch', state.available_branches, state.head, function(value) 448 | if value then 449 | state.head = value 450 | M.render_form(bufnr) 451 | M.update_preview(bufnr) 452 | end 453 | end) 454 | end 455 | 456 | function M.select_base_branch(bufnr) 457 | local state = vim.b[bufnr].github_pr_creator 458 | if #state.available_branches == 0 then 459 | vim.notify('No branches available', vim.log.levels.WARN) 460 | return 461 | end 462 | form_components.single_select('Select Base Branch', state.available_branches, state.base, function(value) 463 | if value then 464 | state.base = value 465 | M.render_form(bufnr) 466 | M.update_preview(bufnr) 467 | end 468 | end) 469 | end 470 | 471 | function M.toggle_draft(bufnr) 472 | local state = vim.b[bufnr].github_pr_creator 473 | state.draft = not state.draft 474 | M.render_form(bufnr) 475 | M.update_preview(bufnr) 476 | vim.notify('Draft mode: ' .. (state.draft and 'ON' or 'OFF'), vim.log.levels.INFO) 477 | end 478 | 479 | function M.select_reviewers(bufnr) 480 | local state = vim.b[bufnr].github_pr_creator 481 | if #state.available_reviewers == 0 then 482 | vim.notify('No collaborators available', vim.log.levels.WARN) 483 | return 484 | end 485 | local items = vim.tbl_map(function(r) 486 | return { display = '@' .. r, value = r } 487 | end, state.available_reviewers) 488 | 489 | form_components.multi_select('Select Reviewers', items, function(selected) 490 | state.reviewers = selected 491 | M.render_form(bufnr) 492 | M.update_preview(bufnr) 493 | end) 494 | end 495 | 496 | function M.select_labels(bufnr) 497 | local state = vim.b[bufnr].github_pr_creator 498 | if #state.available_labels == 0 then 499 | vim.notify('No labels available', vim.log.levels.WARN) 500 | return 501 | end 502 | local items = vim.tbl_map(function(l) 503 | return { display = l, value = l } 504 | end, state.available_labels) 505 | 506 | form_components.multi_select('Select Labels', items, function(selected) 507 | state.labels = selected 508 | M.render_form(bufnr) 509 | M.update_preview(bufnr) 510 | end) 511 | end 512 | 513 | -- Submit the PR 514 | function M.submit_pr(bufnr) 515 | local state = vim.b[bufnr].github_pr_creator 516 | 517 | -- Validation 518 | if state.title == '' then 519 | vim.notify('Please enter a PR title', vim.log.levels.WARN) 520 | return 521 | end 522 | 523 | if state.head == '' then 524 | vim.notify('Please select a head branch', vim.log.levels.WARN) 525 | return 526 | end 527 | 528 | if state.base == '' then 529 | vim.notify('Please select a base branch', vim.log.levels.WARN) 530 | return 531 | end 532 | 533 | vim.notify('Creating PR...', vim.log.levels.INFO) 534 | 535 | local options = { 536 | title = state.title, 537 | body = state.body, 538 | head = state.head, 539 | base = state.base, 540 | draft = state.draft, 541 | reviewers = state.reviewers, 542 | labels = state.labels, 543 | } 544 | 545 | pr_actions.create_advanced(state.repo, options, function(success, data, error) 546 | vim.schedule(function() 547 | if success then 548 | vim.notify('PR #' .. data.number .. ' created successfully!', vim.log.levels.INFO) 549 | vim.cmd('tabclose') 550 | 551 | -- Open the created PR 552 | require('github').open_pr_detail(data.number) 553 | else 554 | vim.notify('Failed to create PR: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 555 | end 556 | end) 557 | end) 558 | end 559 | 560 | return M 561 | -------------------------------------------------------------------------------- /lua/github/ui/pr_viewer.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local buffer = require('github.ui.buffer') 3 | local diff_view = require('github.ui.diff_view') 4 | local client = require('github.api.client') 5 | local data_helper = require('github.utils.data_helper') 6 | 7 | -- Helper function to format datetime 8 | function M.format_datetime(iso_string) 9 | -- Handle nil or empty values 10 | if not iso_string then 11 | return '' 12 | end 13 | 14 | -- Convert to string if it's userdata or other type 15 | local datetime_str = tostring(iso_string) 16 | 17 | if datetime_str == '' or datetime_str == 'nil' then 18 | return '' 19 | end 20 | 21 | -- Parse ISO 8601 datetime (e.g., "2024-01-15T10:30:00Z") 22 | local year, month, day, hour, min, sec = datetime_str:match('(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)') 23 | if not year then 24 | -- If parsing fails, return a shortened version 25 | if #datetime_str > 20 then 26 | return datetime_str:sub(1, 10) -- Just show the date part 27 | end 28 | return datetime_str 29 | end 30 | 31 | -- Convert to time_t for relative time calculation 32 | local ok, time_table = pcall(function() 33 | return { 34 | year = tonumber(year), 35 | month = tonumber(month), 36 | day = tonumber(day), 37 | hour = tonumber(hour), 38 | min = tonumber(min), 39 | sec = tonumber(sec) 40 | } 41 | end) 42 | 43 | if not ok then 44 | return datetime_str:sub(1, 10) -- Fallback to date only 45 | end 46 | 47 | local timestamp = os.time(time_table) 48 | local now = os.time() 49 | local diff = now - timestamp 50 | 51 | -- Handle future dates (might happen due to timezone differences) 52 | if diff < 0 then 53 | diff = 0 54 | end 55 | 56 | -- Format relative time 57 | if diff < 60 then 58 | return 'just now' 59 | elseif diff < 3600 then 60 | local mins = math.floor(diff / 60) 61 | return mins .. 'm ago' 62 | elseif diff < 86400 then 63 | local hours = math.floor(diff / 3600) 64 | return hours .. 'h ago' 65 | elseif diff < 604800 then 66 | local days = math.floor(diff / 86400) 67 | return days .. 'd ago' 68 | else 69 | -- Show absolute date for older items 70 | return string.format('%s/%s/%s', month, day, year) 71 | end 72 | end 73 | 74 | -- Open comprehensive PR viewer with split layout 75 | -- Left sidebar: PR info + file list (30% width) 76 | -- Main buffer: Diff view for selected file (70% width) 77 | function M.open_pr_viewer(repo, pr_number) 78 | -- Close all existing GitHub windows first 79 | local window = require('github.ui.window') 80 | window.close_all_windows() 81 | 82 | -- Take over current window with a new tab or split based on preference 83 | -- For full takeover, use tabnew 84 | vim.cmd('tabnew') 85 | 86 | -- Create vertical split: left sidebar + main buffer 87 | vim.cmd('vsplit') 88 | 89 | -- Move to left window (sidebar) 90 | vim.cmd('wincmd h') 91 | local sidebar_winid = vim.api.nvim_get_current_win() 92 | 93 | -- Move to right window (main buffer) 94 | vim.cmd('wincmd l') 95 | local main_winid = vim.api.nvim_get_current_win() 96 | 97 | -- Set sidebar width to 30% 98 | local total_width = vim.o.columns 99 | local sidebar_width = math.floor(total_width * 0.3) 100 | vim.api.nvim_win_set_width(sidebar_winid, sidebar_width) 101 | 102 | -- Create buffers 103 | local sidebar_bufnr = vim.api.nvim_create_buf(false, true) 104 | local main_bufnr = vim.api.nvim_create_buf(false, true) 105 | 106 | -- Setup sidebar buffer 107 | local ok1 = pcall(vim.api.nvim_buf_set_name, sidebar_bufnr, 'github_pr_sidebar_' .. pr_number) 108 | if not ok1 then 109 | pcall(vim.api.nvim_buf_set_name, sidebar_bufnr, 'github_pr_sidebar_' .. os.time()) 110 | end 111 | vim.b[sidebar_bufnr].github_type = 'github_pr_sidebar' 112 | vim.bo[sidebar_bufnr].buftype = 'nofile' 113 | vim.bo[sidebar_bufnr].swapfile = false 114 | vim.bo[sidebar_bufnr].bufhidden = 'hide' 115 | vim.bo[sidebar_bufnr].modifiable = true 116 | 117 | -- Setup main buffer 118 | local ok2 = pcall(vim.api.nvim_buf_set_name, main_bufnr, 'github_pr_diff_' .. pr_number) 119 | if not ok2 then 120 | pcall(vim.api.nvim_buf_set_name, main_bufnr, 'github_pr_diff_' .. os.time()) 121 | end 122 | vim.b[main_bufnr].github_type = buffer.BUFFER_TYPES.DIFF 123 | vim.bo[main_bufnr].buftype = 'nofile' 124 | vim.bo[main_bufnr].swapfile = false 125 | vim.bo[main_bufnr].bufhidden = 'hide' 126 | vim.bo[main_bufnr].modifiable = true 127 | vim.bo[main_bufnr].filetype = 'diff' 128 | 129 | -- Validate buffers 130 | if not vim.api.nvim_buf_is_valid(sidebar_bufnr) or not vim.api.nvim_buf_is_valid(main_bufnr) then 131 | vim.notify('Failed to create PR viewer buffers', vim.log.levels.ERROR) 132 | return 133 | end 134 | 135 | -- Set buffers in windows 136 | vim.api.nvim_win_set_buf(sidebar_winid, sidebar_bufnr) 137 | vim.api.nvim_win_set_buf(main_winid, main_bufnr) 138 | 139 | buffer.setup_window_options(sidebar_winid) 140 | buffer.setup_window_options(main_winid) 141 | 142 | -- Show loading 143 | vim.api.nvim_buf_set_lines(sidebar_bufnr, 0, -1, false, { 'Loading PR...' }) 144 | vim.api.nvim_buf_set_lines(main_bufnr, 0, -1, false, { 'Loading diff...' }) 145 | 146 | -- Fetch PR data and diff 147 | client.get_pr(repo, pr_number, function(success, pr_data, error) 148 | if not success then 149 | vim.schedule(function() 150 | vim.notify('Failed to load PR: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 151 | end) 152 | return 153 | end 154 | 155 | -- Fetch diff 156 | client.get_pr_diff(repo, pr_number, function(diff_success, diff_text, diff_error) 157 | vim.schedule(function() 158 | if not vim.api.nvim_buf_is_valid(sidebar_bufnr) then 159 | return 160 | end 161 | 162 | -- Parse diff to get files 163 | local diff_parser = require('github.utils.diff_parser') 164 | local files = {} 165 | if diff_success and diff_text then 166 | files = diff_parser.parse_unified_diff(diff_text) 167 | end 168 | 169 | -- Store data in sidebar buffer 170 | vim.b[sidebar_bufnr].github_pr = pr_data 171 | vim.b[sidebar_bufnr].github_pr_files = files 172 | vim.b[sidebar_bufnr].github_diff_text = diff_text 173 | vim.b[sidebar_bufnr].github_selected_file_idx = 1 174 | vim.b[sidebar_bufnr].github_main_bufnr = main_bufnr 175 | vim.b[sidebar_bufnr].github_repo = repo 176 | vim.b[sidebar_bufnr].github_pr_number = pr_number 177 | 178 | -- Store data in main buffer 179 | vim.b[main_bufnr].github_pr = pr_data 180 | vim.b[main_bufnr].github_repo = repo 181 | vim.b[main_bufnr].github_pr_number = pr_number 182 | vim.b[main_bufnr].github_sidebar_bufnr = sidebar_bufnr 183 | 184 | -- Render PR info and files list in sidebar 185 | M.render_sidebar(sidebar_bufnr, pr_data, files) 186 | 187 | -- Setup keymaps 188 | M.setup_pr_viewer_keymaps(sidebar_bufnr, main_bufnr) 189 | 190 | -- Render first file's diff in main buffer if available 191 | if #files > 0 then 192 | M.show_file_diff(sidebar_bufnr, 1) 193 | else 194 | -- Show full PR diff if no files parsed 195 | if diff_text then 196 | vim.bo[main_bufnr].modifiable = true 197 | vim.api.nvim_buf_set_lines(main_bufnr, 0, -1, false, vim.split(diff_text, '\n')) 198 | vim.bo[main_bufnr].modifiable = false 199 | end 200 | end 201 | 202 | -- Focus on sidebar 203 | vim.api.nvim_set_current_win(sidebar_winid) 204 | end) 205 | end) 206 | end) 207 | end 208 | 209 | -- Render sidebar with PR info and files list 210 | function M.render_sidebar(bufnr, pr, files) 211 | local lines = {} 212 | local highlights = {} 213 | 214 | -- PR Header 215 | local title = pr.title or 'No title' 216 | title = vim.split(title, '\n')[1] or title 217 | if pr.isDraft then 218 | title = '[DRAFT] ' .. title 219 | end 220 | 221 | table.insert(lines, string.format('━━━ PR #%d ━━━', pr.number)) 222 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 223 | table.insert(lines, title) 224 | table.insert(highlights, { #lines - 1, 0, -1, 'Title' }) 225 | table.insert(lines, '') 226 | 227 | -- Status line 228 | local state = pr.state or 'open' 229 | local state_icon = state == 'open' and '●' or state == 'closed' and '○' or '◆' 230 | local state_text = state_icon .. ' ' .. string.upper(state:sub(1,1)) .. state:sub(2) 231 | 232 | if pr.isDraft then 233 | state_text = state_text .. ' | ◇ DRAFT' 234 | end 235 | 236 | table.insert(lines, state_text) 237 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubPr' .. state:gsub("^%l", string.upper) }) 238 | 239 | -- Author 240 | local author = data_helper.safe_get_login(pr.author) 241 | table.insert(lines, string.format('👤 Author: @%s', author)) 242 | 243 | -- Timestamps 244 | if pr.createdAt then 245 | local created = M.format_datetime(pr.createdAt) 246 | table.insert(lines, string.format('📅 Created: %s', created)) 247 | end 248 | 249 | if pr.updatedAt then 250 | local updated = M.format_datetime(pr.updatedAt) 251 | table.insert(lines, string.format('🔄 Updated: %s', updated)) 252 | end 253 | 254 | if pr.mergedAt then 255 | local merged = M.format_datetime(pr.mergedAt) 256 | local merger = data_helper.safe_get_login(pr.mergedBy) 257 | table.insert(lines, string.format('✅ Merged: %s by @%s', merged, merger)) 258 | end 259 | 260 | table.insert(lines, '') 261 | 262 | -- Branches 263 | if pr.headRefName and pr.baseRefName then 264 | table.insert(lines, string.format('🌿 %s → %s', pr.headRefName, pr.baseRefName)) 265 | table.insert(lines, '') 266 | end 267 | 268 | -- Review status 269 | if pr.reviewDecision then 270 | local decision_icon = pr.reviewDecision == 'APPROVED' and '✅' or pr.reviewDecision == 'CHANGES_REQUESTED' and '❌' or '⏳' 271 | table.insert(lines, string.format('📋 Review: %s %s', decision_icon, pr.reviewDecision)) 272 | else 273 | table.insert(lines, '📋 Review: ⏳ Pending') 274 | end 275 | 276 | -- Requested Reviewers 277 | if pr.reviewRequests and #pr.reviewRequests > 0 then 278 | table.insert(lines, '') 279 | table.insert(lines, '━━━ Requested Reviewers ━━━') 280 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 281 | for _, request in ipairs(pr.reviewRequests) do 282 | local reviewer = data_helper.safe_get_field(request, 'requestedReviewer') 283 | if reviewer then 284 | local reviewer_login = data_helper.safe_get_login(reviewer) 285 | table.insert(lines, string.format(' ⏳ @%s', reviewer_login)) 286 | end 287 | end 288 | end 289 | 290 | -- Completed Reviews 291 | if pr.reviews and #pr.reviews > 0 then 292 | table.insert(lines, '') 293 | table.insert(lines, '━━━ Reviews ━━━') 294 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 295 | 296 | -- Group reviews by author and show latest 297 | local review_map = {} 298 | for _, review in ipairs(pr.reviews) do 299 | local author = data_helper.safe_get_field(review, 'author') 300 | local state = data_helper.safe_get_field(review, 'state') 301 | if author and state ~= 'PENDING' then 302 | local login = data_helper.safe_get_login(author) 303 | if login ~= 'unknown' then 304 | -- Keep only the latest review from each author 305 | local submitted_at = data_helper.safe_get_field(review, 'submittedAt') 306 | if not review_map[login] or (submitted_at and review_map[login].submittedAt and submitted_at > review_map[login].submittedAt) then 307 | review_map[login] = review 308 | end 309 | end 310 | end 311 | end 312 | 313 | -- Display reviews 314 | for login, review in pairs(review_map) do 315 | local state = data_helper.safe_get_field(review, 'state', '') 316 | local icon = state == 'APPROVED' and '✅' or state == 'CHANGES_REQUESTED' and '❌' or state == 'COMMENTED' and '💬' or '○' 317 | local submitted_at = data_helper.safe_get_field(review, 'submittedAt') 318 | local time = submitted_at and M.format_datetime(submitted_at) or '' 319 | table.insert(lines, string.format(' %s @%s - %s', icon, login, time)) 320 | local body = data_helper.safe_get_field(review, 'body') 321 | if body and body ~= '' then 322 | local body_preview = vim.split(tostring(body), '\n')[1] or body 323 | if #body_preview > 50 then 324 | body_preview = body_preview:sub(1, 47) .. '...' 325 | end 326 | table.insert(lines, string.format(' "%s"', body_preview)) 327 | end 328 | end 329 | end 330 | 331 | -- General Comments 332 | if pr.comments and #pr.comments > 0 then 333 | table.insert(lines, '') 334 | table.insert(lines, string.format('💬 %d comment(s)', #pr.comments)) 335 | end 336 | 337 | -- CI Status 338 | local status_rollup = data_helper.safe_get_field(pr, 'statusCheckRollup') 339 | if status_rollup then 340 | local ci_state = data_helper.safe_get_field(status_rollup, 'state') 341 | if ci_state then 342 | table.insert(lines, '') 343 | local ci_icon = ci_state == 'SUCCESS' and '✅' or ci_state == 'FAILURE' and '❌' or '⏳' 344 | table.insert(lines, string.format('🔧 CI: %s %s', ci_icon, ci_state)) 345 | end 346 | end 347 | 348 | table.insert(lines, '') 349 | table.insert(lines, '━━━ Files Changed ━━━') 350 | table.insert(highlights, { #lines - 1, 0, -1, 'GithubHeader' }) 351 | 352 | if #files == 0 then 353 | table.insert(lines, '') 354 | table.insert(lines, 'No files changed') 355 | else 356 | -- Calculate totals 357 | local total_additions = 0 358 | local total_deletions = 0 359 | for _, file in ipairs(files) do 360 | total_additions = total_additions + (file.additions or 0) 361 | total_deletions = total_deletions + (file.deletions or 0) 362 | end 363 | 364 | table.insert(lines, '') 365 | table.insert(lines, string.format('📊 %d files | +%d -%d', #files, total_additions, total_deletions)) 366 | table.insert(lines, '') 367 | 368 | -- List files 369 | local selected_idx = vim.b[bufnr].github_selected_file_idx or 1 370 | for i, file in ipairs(files) do 371 | local prefix = i == selected_idx and '▶ ' or ' ' 372 | local file_name = file.file or 'unknown' 373 | local additions = file.additions or 0 374 | local deletions = file.deletions or 0 375 | 376 | local line = string.format('%s%s +%d -%d', prefix, file_name, additions, deletions) 377 | table.insert(lines, line) 378 | 379 | if i == selected_idx then 380 | table.insert(highlights, { #lines - 1, 0, -1, 'Visual' }) 381 | end 382 | end 383 | end 384 | 385 | -- Review mode indicator 386 | local review_mode = vim.b[bufnr].github_review_mode 387 | if review_mode then 388 | table.insert(lines, '') 389 | table.insert(lines, '━━━ 🔍 REVIEW MODE ACTIVE ━━━') 390 | table.insert(highlights, { #lines - 1, 0, -1, 'WarningMsg' }) 391 | table.insert(lines, 'Press "C" to add inline comments') 392 | table.insert(lines, 'Press "a" to approve | "r" to request changes') 393 | end 394 | 395 | -- Edit mode indicator 396 | local edit_mode = vim.b[bufnr].github_edit_mode 397 | if edit_mode then 398 | table.insert(lines, '') 399 | table.insert(lines, '━━━ ✏️ EDIT MODE ACTIVE ━━━') 400 | table.insert(highlights, { #lines - 1, 0, -1, 'WarningMsg' }) 401 | table.insert(lines, ' T: Edit title') 402 | table.insert(lines, ' D: Edit description') 403 | table.insert(lines, ' A: Manage assignees') 404 | table.insert(lines, ' L: Manage labels') 405 | table.insert(lines, ' R: Request reviews') 406 | table.insert(lines, ' e: Exit edit mode') 407 | end 408 | 409 | table.insert(lines, '') 410 | table.insert(lines, '━━━ Keymaps ━━━') 411 | table.insert(highlights, { #lines - 1, 0, -1, 'Comment' }) 412 | table.insert(lines, '📂 Navigation:') 413 | table.insert(lines, ' j/k: Navigate files') 414 | table.insert(lines, ' : View file diff') 415 | table.insert(lines, ' : Switch to diff') 416 | table.insert(lines, '') 417 | table.insert(lines, '📝 Review:') 418 | table.insert(lines, ' S: Start review mode') 419 | table.insert(lines, ' C: Add inline comment (in diff)') 420 | table.insert(lines, ' vC: Comment on range (visual)') 421 | table.insert(lines, ' c: Add general comment') 422 | table.insert(lines, '') 423 | table.insert(lines, '✅ Actions:') 424 | table.insert(lines, ' a: Approve PR') 425 | table.insert(lines, ' r: Request changes') 426 | table.insert(lines, ' o: Open in browser') 427 | table.insert(lines, ' e: Toggle edit mode') 428 | table.insert(lines, ' q: Close tab') 429 | 430 | vim.bo[bufnr].modifiable = true 431 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) 432 | vim.bo[bufnr].modifiable = false 433 | 434 | -- Apply highlights 435 | local ns = vim.api.nvim_create_namespace('github_pr_viewer') 436 | for _, hl in ipairs(highlights) do 437 | vim.api.nvim_buf_add_highlight(bufnr, ns, hl[4], hl[1], hl[2], hl[3]) 438 | end 439 | end 440 | 441 | -- Show diff for selected file 442 | function M.show_file_diff(sidebar_bufnr, file_idx) 443 | local files = vim.b[sidebar_bufnr].github_pr_files 444 | if not files or #files == 0 then 445 | return 446 | end 447 | 448 | if file_idx < 1 or file_idx > #files then 449 | return 450 | end 451 | 452 | local main_bufnr = vim.b[sidebar_bufnr].github_main_bufnr 453 | if not main_bufnr or not vim.api.nvim_buf_is_valid(main_bufnr) then 454 | return 455 | end 456 | 457 | local file = files[file_idx] 458 | 459 | -- Update selection 460 | vim.b[sidebar_bufnr].github_selected_file_idx = file_idx 461 | 462 | -- Re-render sidebar to update selection indicator 463 | local pr = vim.b[sidebar_bufnr].github_pr 464 | M.render_sidebar(sidebar_bufnr, pr, files) 465 | 466 | -- Render file diff in main buffer 467 | local diff_text = vim.b[sidebar_bufnr].github_diff_text 468 | if diff_text then 469 | diff_view.render_diff(main_bufnr, diff_text, file.file) 470 | end 471 | end 472 | 473 | -- Setup keymaps for PR viewer 474 | function M.setup_pr_viewer_keymaps(sidebar_bufnr, main_bufnr) 475 | -- Sidebar keymaps 476 | local function map_sidebar(key, fn) 477 | vim.keymap.set('n', key, fn, { buffer = sidebar_bufnr, silent = true }) 478 | end 479 | 480 | -- Main buffer keymaps 481 | local function map_main(key, fn) 482 | vim.keymap.set('n', key, fn, { buffer = main_bufnr, silent = true }) 483 | end 484 | 485 | -- Navigate files (sidebar) 486 | map_sidebar('j', function() 487 | local files = vim.b[sidebar_bufnr].github_pr_files or {} 488 | if #files == 0 then return end 489 | 490 | local current_idx = vim.b[sidebar_bufnr].github_selected_file_idx or 1 491 | local next_idx = math.min(current_idx + 1, #files) 492 | M.show_file_diff(sidebar_bufnr, next_idx) 493 | end) 494 | 495 | map_sidebar('k', function() 496 | local files = vim.b[sidebar_bufnr].github_pr_files or {} 497 | if #files == 0 then return end 498 | 499 | local current_idx = vim.b[sidebar_bufnr].github_selected_file_idx or 1 500 | local prev_idx = math.max(current_idx - 1, 1) 501 | M.show_file_diff(sidebar_bufnr, prev_idx) 502 | end) 503 | 504 | -- View selected file (sidebar) 505 | map_sidebar('', function() 506 | local current_idx = vim.b[sidebar_bufnr].github_selected_file_idx or 1 507 | M.show_file_diff(sidebar_bufnr, current_idx) 508 | end) 509 | 510 | -- Start review (sidebar) 511 | map_sidebar('S', function() 512 | M.start_review_mode(sidebar_bufnr) 513 | end) 514 | 515 | -- Add inline comment in review mode (main buffer only) 516 | -- Normal mode: single line comment 517 | map_main('C', function() 518 | M.add_inline_comment(sidebar_bufnr, main_bufnr, false) 519 | end) 520 | 521 | -- Visual mode: multi-line comment 522 | vim.keymap.set('v', 'C', function() 523 | M.add_inline_comment(sidebar_bufnr, main_bufnr, true) 524 | end, { buffer = main_bufnr, silent = true }) 525 | 526 | -- Add general comment (both buffers) 527 | local comment_fn = function() 528 | local pr = vim.b[sidebar_bufnr].github_pr 529 | if pr then 530 | require('github').add_pr_comment(pr.number) 531 | end 532 | end 533 | map_sidebar('c', comment_fn) 534 | map_main('c', comment_fn) 535 | 536 | -- Approve (both buffers) 537 | local approve_fn = function() 538 | M.approve_pr(sidebar_bufnr) 539 | end 540 | map_sidebar('a', approve_fn) 541 | map_main('a', approve_fn) 542 | 543 | -- Request changes (both buffers) 544 | local request_changes_fn = function() 545 | M.request_changes_pr(sidebar_bufnr) 546 | end 547 | map_sidebar('r', request_changes_fn) 548 | map_main('r', request_changes_fn) 549 | 550 | -- Open in browser (both buffers) 551 | local browser_fn = function() 552 | local pr = vim.b[sidebar_bufnr].github_pr 553 | if pr then 554 | local cmd = { 'pr', 'view', tostring(pr.number), '--web' } 555 | require('github.api.client').run_command(cmd, function(success, output, error) 556 | if not success then 557 | vim.notify('Failed to open in browser: ' .. (error or ''), vim.log.levels.ERROR) 558 | end 559 | end) 560 | end 561 | end 562 | map_sidebar('o', browser_fn) 563 | map_main('o', browser_fn) 564 | 565 | -- Close (both buffers) - closes the entire tab 566 | local close_fn = function() 567 | vim.cmd('tabclose') 568 | end 569 | map_sidebar('q', close_fn) 570 | map_main('q', close_fn) 571 | 572 | -- Toggle edit mode (sidebar only) 573 | map_sidebar('e', function() 574 | local pr_editor = require('github.feature.pr_editor') 575 | if vim.b[sidebar_bufnr].github_edit_mode then 576 | pr_editor.disable_edit_mode(sidebar_bufnr) 577 | else 578 | pr_editor.enable_edit_mode(sidebar_bufnr) 579 | end 580 | end) 581 | 582 | -- Switch between sidebar and main buffer 583 | map_sidebar('', function() 584 | local wins = vim.fn.win_findbuf(main_bufnr) 585 | if #wins > 0 then 586 | vim.api.nvim_set_current_win(wins[1]) 587 | end 588 | end) 589 | 590 | map_main('', function() 591 | local wins = vim.fn.win_findbuf(sidebar_bufnr) 592 | if #wins > 0 then 593 | vim.api.nvim_set_current_win(wins[1]) 594 | end 595 | end) 596 | end 597 | 598 | -- Start review mode - launches interactive review UI 599 | function M.start_review_mode(sidebar_bufnr) 600 | local pr = vim.b[sidebar_bufnr].github_pr 601 | local repo = vim.b[sidebar_bufnr].github_repo 602 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 603 | local files = vim.b[sidebar_bufnr].github_pr_files or {} 604 | 605 | if not pr or not repo or not pr_number then 606 | vim.notify('Failed to start review: Missing PR data', vim.log.levels.ERROR) 607 | return 608 | end 609 | 610 | -- Launch the interactive review UI 611 | local review_ui = require('github.ui.review_ui') 612 | review_ui.open_review_ui(repo, pr_number, pr, files) 613 | end 614 | 615 | -- Approve PR (handles review mode) 616 | function M.approve_pr(sidebar_bufnr) 617 | local pr = vim.b[sidebar_bufnr].github_pr 618 | local repo = vim.b[sidebar_bufnr].github_repo 619 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 620 | local review_mode = vim.b[sidebar_bufnr].github_review_mode 621 | local review_comments = vim.b[sidebar_bufnr].github_review_comments or {} 622 | 623 | if not pr or not repo or not pr_number then 624 | vim.notify('Failed to approve: Missing PR data', vim.log.levels.ERROR) 625 | return 626 | end 627 | 628 | vim.ui.input({ prompt = 'Approval comment (optional): ' }, function(body) 629 | if body == nil then 630 | return -- User cancelled 631 | end 632 | 633 | if review_mode and #review_comments > 0 then 634 | -- Show collected comments 635 | local comment_summary = string.format('\n\nInline comments:\n') 636 | for _, comment in ipairs(review_comments) do 637 | local line_str 638 | if comment.start_line == comment.end_line then 639 | line_str = string.format('L%d', comment.start_line) 640 | else 641 | line_str = string.format('L%d-L%d', comment.start_line, comment.end_line) 642 | end 643 | comment_summary = comment_summary .. string.format('- %s:%s: %s\n', comment.file, line_str, comment.body) 644 | end 645 | 646 | local final_body = (body or '') .. comment_summary 647 | 648 | vim.notify('Submitting approval with ' .. #review_comments .. ' comment(s)...', vim.log.levels.INFO) 649 | 650 | -- Use gh pr review to approve 651 | local cmd = { 'pr', 'review', tostring(pr_number), '--repo', repo, '--approve' } 652 | if final_body and final_body ~= '' then 653 | table.insert(cmd, '--body') 654 | table.insert(cmd, final_body) 655 | end 656 | 657 | client.run_command(cmd, function(success, output, error) 658 | vim.schedule(function() 659 | if success then 660 | vim.notify('✓ PR approved!', vim.log.levels.INFO) 661 | -- Clear review mode 662 | vim.b[sidebar_bufnr].github_review_mode = false 663 | vim.b[sidebar_bufnr].github_review_comments = {} 664 | -- Reload PR data 665 | M.reload_pr(sidebar_bufnr) 666 | else 667 | vim.notify('Failed to approve: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 668 | end 669 | end) 670 | end) 671 | else 672 | -- Direct approval (not in review mode or no comments) 673 | require('github').approve_pr(pr_number) 674 | end 675 | end) 676 | end 677 | 678 | -- Request changes to PR (handles review mode) 679 | function M.request_changes_pr(sidebar_bufnr) 680 | local pr = vim.b[sidebar_bufnr].github_pr 681 | local repo = vim.b[sidebar_bufnr].github_repo 682 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 683 | local review_mode = vim.b[sidebar_bufnr].github_review_mode 684 | local review_comments = vim.b[sidebar_bufnr].github_review_comments or {} 685 | 686 | if not pr or not repo or not pr_number then 687 | vim.notify('Failed to request changes: Missing PR data', vim.log.levels.ERROR) 688 | return 689 | end 690 | 691 | vim.ui.input({ prompt = 'Reason for changes: ' }, function(body) 692 | if not body or body == '' then 693 | vim.notify('Please provide a reason for requesting changes', vim.log.levels.WARN) 694 | return 695 | end 696 | 697 | if review_mode and #review_comments > 0 then 698 | -- Show collected comments 699 | local comment_summary = string.format('\n\nInline comments:\n') 700 | for _, comment in ipairs(review_comments) do 701 | local line_str 702 | if comment.start_line == comment.end_line then 703 | line_str = string.format('L%d', comment.start_line) 704 | else 705 | line_str = string.format('L%d-L%d', comment.start_line, comment.end_line) 706 | end 707 | comment_summary = comment_summary .. string.format('- %s:%s: %s\n', comment.file, line_str, comment.body) 708 | end 709 | 710 | local final_body = body .. comment_summary 711 | 712 | vim.notify('Submitting review with ' .. #review_comments .. ' comment(s)...', vim.log.levels.INFO) 713 | 714 | -- Use gh pr review to request changes 715 | local cmd = { 'pr', 'review', tostring(pr_number), '--repo', repo, '--request-changes', '--body', final_body } 716 | 717 | client.run_command(cmd, function(success, output, error) 718 | vim.schedule(function() 719 | if success then 720 | vim.notify('✓ Changes requested', vim.log.levels.INFO) 721 | -- Clear review mode 722 | vim.b[sidebar_bufnr].github_review_mode = false 723 | vim.b[sidebar_bufnr].github_review_comments = {} 724 | -- Reload PR data 725 | M.reload_pr(sidebar_bufnr) 726 | else 727 | vim.notify('Failed to request changes: ' .. (error or 'unknown error'), vim.log.levels.ERROR) 728 | end 729 | end) 730 | end) 731 | else 732 | -- Direct request changes (not in review mode) 733 | require('github').request_changes_pr(pr_number) 734 | end 735 | end) 736 | end 737 | 738 | -- Reload PR data 739 | function M.reload_pr(sidebar_bufnr) 740 | local repo = vim.b[sidebar_bufnr].github_repo 741 | local pr_number = vim.b[sidebar_bufnr].github_pr_number 742 | 743 | if not repo or not pr_number then 744 | return 745 | end 746 | 747 | client.get_pr(repo, pr_number, function(success, pr_data, error) 748 | if not success then 749 | return 750 | end 751 | 752 | vim.schedule(function() 753 | if not vim.api.nvim_buf_is_valid(sidebar_bufnr) then 754 | return 755 | end 756 | 757 | vim.b[sidebar_bufnr].github_pr = pr_data 758 | M.render_sidebar(sidebar_bufnr, pr_data, vim.b[sidebar_bufnr].github_pr_files or {}) 759 | end) 760 | end) 761 | end 762 | 763 | -- Add inline comment in review mode (stores locally until review submission) 764 | -- Supports both single line and visual range comments 765 | function M.add_inline_comment(sidebar_bufnr, main_bufnr, is_visual) 766 | local review_mode = vim.b[sidebar_bufnr].github_review_mode 767 | local files = vim.b[sidebar_bufnr].github_pr_files 768 | local selected_idx = vim.b[sidebar_bufnr].github_selected_file_idx 769 | 770 | if not review_mode then 771 | vim.notify('Start a review first (press "S")', vim.log.levels.WARN) 772 | return 773 | end 774 | 775 | if not files or #files == 0 or not selected_idx then 776 | vim.notify('No file selected', vim.log.levels.WARN) 777 | return 778 | end 779 | 780 | local current_file = files[selected_idx] 781 | if not current_file or not current_file.file then 782 | vim.notify('Failed to get current file', vim.log.levels.ERROR) 783 | return 784 | end 785 | 786 | -- Get the file path 787 | local file_path = current_file.file 788 | 789 | -- Get line number(s) 790 | local start_line, end_line 791 | if is_visual then 792 | -- Get visual selection range 793 | local start_pos = vim.fn.getpos("'<") 794 | local end_pos = vim.fn.getpos("'>") 795 | start_line = start_pos[2] 796 | end_line = end_pos[2] 797 | 798 | -- Exit visual mode properly 799 | local esc = vim.api.nvim_replace_termcodes('', true, false, true) 800 | vim.api.nvim_feedkeys(esc, 'n', false) 801 | 802 | -- Validate and normalize range 803 | if start_line > end_line then 804 | start_line, end_line = end_line, start_line 805 | end 806 | else 807 | -- Single line - current cursor position 808 | local cursor = vim.api.nvim_win_get_cursor(0) 809 | start_line = cursor[1] 810 | end_line = start_line 811 | end 812 | 813 | -- Validate line numbers 814 | local total_lines = vim.api.nvim_buf_line_count(main_bufnr) 815 | if start_line < 1 or start_line > total_lines or end_line < 1 or end_line > total_lines then 816 | vim.notify('Invalid line range selected', vim.log.levels.WARN) 817 | return 818 | end 819 | 820 | -- Check if commenting on diff content (not headers) 821 | local line_content = vim.api.nvim_buf_get_lines(main_bufnr, start_line - 1, start_line, false)[1] 822 | if line_content and (line_content:match('^diff %-%-git') or line_content:match('^@@') or line_content:match('^index ')) then 823 | vim.notify('Cannot comment on diff headers. Comment on actual code lines (+/-)', vim.log.levels.WARN) 824 | return 825 | end 826 | 827 | -- Build prompt message 828 | local prompt_msg 829 | if start_line == end_line then 830 | prompt_msg = string.format('Comment (line %d): ', start_line) 831 | else 832 | prompt_msg = string.format('Comment (lines %d-%d): ', start_line, end_line) 833 | end 834 | 835 | -- Prompt for comment 836 | vim.ui.input({ prompt = prompt_msg }, function(comment_text) 837 | if not comment_text or comment_text == '' then 838 | return 839 | end 840 | 841 | -- Store comment locally 842 | local review_comments = vim.b[sidebar_bufnr].github_review_comments or {} 843 | 844 | local comment_entry = { 845 | file = file_path, 846 | start_line = start_line, 847 | end_line = end_line, 848 | body = comment_text, 849 | } 850 | 851 | table.insert(review_comments, comment_entry) 852 | vim.b[sidebar_bufnr].github_review_comments = review_comments 853 | 854 | -- Also update main buffer 855 | local main_bufnr_ref = vim.b[sidebar_bufnr].github_main_bufnr 856 | if main_bufnr_ref and vim.api.nvim_buf_is_valid(main_bufnr_ref) then 857 | vim.b[main_bufnr_ref].github_review_comments = review_comments 858 | end 859 | 860 | local range_str = start_line == end_line and string.format('L%d', start_line) or string.format('L%d-L%d', start_line, end_line) 861 | vim.notify(string.format('✓ Comment added on %s (%d total). Press "a" or "r" to submit review.', range_str, #review_comments), vim.log.levels.INFO) 862 | end) 863 | end 864 | 865 | return M 866 | --------------------------------------------------------------------------------