diff --git a/README.md b/README.md index 849d219..0cf9a3b 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,17 @@ require("gitlab").setup({ close = { "", "" }, submit = { "", "" }, }, + pipeline = { + created = "", + pending = "", + preparing = "", + scheduled = "", + running = "ﰌ", + canceled = "ﰸ", + skipped = "ﰸ", + success = "✓", + failed = "", + }, }) ``` @@ -174,6 +185,12 @@ require("gitlab").approve() require("gitlab").revoke() ``` +You can view the status of the pipeline for the current MR. To re-trigger failed jobs in the pipeline manually, use your `settings.popup.perform_action` keybinding: + +```lua +require("gitlab").pipeline() +``` + The `add_reviewer` and `delete_reviewer` commands, as well as the `add_assignee` and `delete_assignee` functions, will let you choose from a list of users who are availble in the current project: ```lua @@ -209,6 +226,7 @@ vim.keymap.set("n", "glaa", gitlab.add_assignee) vim.keymap.set("n", "glad", gitlab.delete_assignee) vim.keymap.set("n", "glra", gitlab.add_reviewer) vim.keymap.set("n", "glrd", gitlab.delete_reviewer) +vim.keymap.set("n", "glp", gitlab.pipeline) ``` ## Troubleshooting diff --git a/cmd/approve.go b/cmd/approve.go index be6fd0d..351cbc8 100644 --- a/cmd/approve.go +++ b/cmd/approve.go @@ -11,6 +11,7 @@ func ApproveHandler(w http.ResponseWriter, r *http.Request) { c := r.Context().Value("client").(Client) if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) return } diff --git a/cmd/description.go b/cmd/description.go index 3ebcbe9..4f63f36 100644 --- a/cmd/description.go +++ b/cmd/description.go @@ -22,6 +22,7 @@ func DescriptionHandler(w http.ResponseWriter, r *http.Request) { c := r.Context().Value("client").(Client) w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { + w.Header().Set("Allow", http.MethodPut) c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) return } diff --git a/cmd/info.go b/cmd/info.go index a2f9af7..0432f4a 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -56,6 +56,7 @@ func InfoHandler(w http.ResponseWriter, r *http.Request) { c := r.Context().Value("client").(Client) if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) return } diff --git a/cmd/list_discussions.go b/cmd/list_discussions.go index c091a57..7c6b4f9 100644 --- a/cmd/list_discussions.go +++ b/cmd/list_discussions.go @@ -83,6 +83,7 @@ func ListDiscussionsHandler(w http.ResponseWriter, r *http.Request) { c := r.Context().Value("client").(Client) if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) return } diff --git a/cmd/main.go b/cmd/main.go index 7c1d89e..5105caa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -40,6 +40,7 @@ func main() { m.Handle("/comment", withGitlabContext(http.HandlerFunc(CommentHandler), c)) m.Handle("/reply", withGitlabContext(http.HandlerFunc(ReplyHandler), c)) m.Handle("/members", withGitlabContext(http.HandlerFunc(ProjectMembersHandler), c)) + m.Handle("/pipeline", withGitlabContext(http.HandlerFunc(PipelineHandler), c)) port := fmt.Sprintf(":%s", os.Args[3]) server := &http.Server{ diff --git a/cmd/pipeline.go b/cmd/pipeline.go new file mode 100644 index 0000000..658fc6b --- /dev/null +++ b/cmd/pipeline.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type PipelineRequest struct { + PipelineId int `json:"pipeline_id"` +} + +type RetriggerPipelineResponse struct { + SuccessResponse + Pipeline *gitlab.Pipeline +} + +type GetJobsResponse struct { + SuccessResponse + Jobs []*gitlab.Job +} + +func PipelineHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + GetJobs(w, r) + case http.MethodPost: + RetriggerPipeline(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func GetJobs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + c := r.Context().Value("client").(Client) + + body, err := io.ReadAll(r.Body) + if err != nil { + c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + + var pipelineRequest PipelineRequest + err = json.Unmarshal(body, &pipelineRequest) + if err != nil { + c.handleError(w, err, "Could not read JSON", http.StatusBadRequest) + } + + jobs, res, err := c.git.Jobs.ListPipelineJobs(c.projectId, pipelineRequest.PipelineId, &gitlab.ListJobsOptions{}) + + if err != nil { + c.handleError(w, err, "Could not get pipeline jobs", res.StatusCode) + } + + w.WriteHeader(http.StatusOK) + + response := GetJobsResponse{ + SuccessResponse: SuccessResponse{ + Status: http.StatusOK, + Message: "Jobs fetched successfully", + }, + Jobs: jobs, + } + + json.NewEncoder(w).Encode(response) + +} + +func RetriggerPipeline(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + c := r.Context().Value("client").(Client) + + body, err := io.ReadAll(r.Body) + if err != nil { + c.handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + + var pipelineRequest PipelineRequest + err = json.Unmarshal(body, &pipelineRequest) + if err != nil { + c.handleError(w, err, "Could not read JSON", http.StatusBadRequest) + } + + pipeline, res, err := c.git.Pipelines.RetryPipelineBuild(c.projectId, pipelineRequest.PipelineId) + + if err != nil { + c.handleError(w, err, "Could not retrigger pipeline", res.StatusCode) + } + + w.WriteHeader(http.StatusOK) + response := RetriggerPipelineResponse{ + SuccessResponse: SuccessResponse{ + Message: "Pipeline retriggered", + Status: http.StatusOK, + }, + Pipeline: pipeline, + } + + json.NewEncoder(w).Encode(response) + +} diff --git a/cmd/reply.go b/cmd/reply.go index ae13733..8979255 100644 --- a/cmd/reply.go +++ b/cmd/reply.go @@ -43,6 +43,7 @@ func ReplyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) return } diff --git a/cmd/revisions.go b/cmd/revisions.go index 3ef4020..7b6f4a3 100644 --- a/cmd/revisions.go +++ b/cmd/revisions.go @@ -18,6 +18,7 @@ func RevisionsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed) return diff --git a/cmd/revoke.go b/cmd/revoke.go index 22bdc4c..4e9026b 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -11,6 +11,7 @@ func RevokeHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) c.handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed) return diff --git a/lua/gitlab/actions/discussions.lua b/lua/gitlab/actions/discussions.lua index ad267f3..cc0ec98 100644 --- a/lua/gitlab/actions/discussions.lua +++ b/lua/gitlab/actions/discussions.lua @@ -290,10 +290,8 @@ M.rebuild_unlinked_discussion_tree = function() end M.switch_can_edit_bufs = function(bool) - vim.api.nvim_buf_set_option(M.unlinked_section_bufnr, 'modifiable', bool) - vim.api.nvim_buf_set_option(M.unlinked_section_bufnr, "readonly", not bool) - vim.api.nvim_buf_set_option(M.linked_section_bufnr, 'modifiable', bool) - vim.api.nvim_buf_set_option(M.linked_section_bufnr, "readonly", not bool) + u.switch_can_edit_buf(M.unlinked_section_bufnr, bool) + u.switch_can_edit_buf(M.linked_section_bufnr, bool) end M.add_discussion = function(arg) diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua new file mode 100644 index 0000000..2b2cca2 --- /dev/null +++ b/lua/gitlab/actions/pipeline.lua @@ -0,0 +1,96 @@ +-- This module is responsible for the MR pipline +-- This lets the user see the current status of the pipeline +-- and retrigger the pipeline from within the editor +local Popup = require("nui.popup") +local state = require("gitlab.state") +local job = require("gitlab.job") +local u = require("gitlab.utils") +local M = {} + +-- The function will render the Pipeline state in a popup +M.open = function() + local pipeline = state.INFO.pipeline + + if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then + vim.notify("Pipeline not found", vim.log.levels.WARN) + return + end + + local body = { pipeline_id = state.INFO.pipeline.id } + job.run_job("/pipeline", "GET", body, function(data) + local pipeline_jobs = u.reverse(type(data.Jobs) == "table" and data.Jobs or {}) + + local width = string.len(pipeline.web_url) + 10 + local height = 6 + #pipeline_jobs + 3 + + local pipeline_popup = Popup(u.create_popup_state("Loading Pipeline...", width, height)) + pipeline_popup:mount() + + local bufnr = vim.api.nvim_get_current_buf() + vim.opt_local.wrap = false + + local lines = {} + + u.switch_can_edit_buf(bufnr, true) + table.insert(lines, string.format("Status: %s (%s)", state.settings.pipeline[pipeline.status], pipeline.status)) + table.insert(lines, "") + table.insert(lines, string.format("Last Run: %s", u.format_date(pipeline.created_at))) + table.insert(lines, string.format("Url: %s", pipeline.web_url)) + table.insert(lines, string.format("Triggered By: %s", pipeline.source)) + + table.insert(lines, "") + table.insert(lines, "Jobs:") + for _, pipeline_job in ipairs(pipeline_jobs) do + table.insert(lines, + string.format("%s (%s) %s", state.settings.pipeline[pipeline_job.status], pipeline_job.status, pipeline_job.name)) + end + + vim.schedule(function() + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + M.color_status(pipeline.status, bufnr, lines[1], 1) + + for i, pipeline_job in ipairs(pipeline_jobs) do + M.color_status(pipeline_job.status, bufnr, lines[7 + i], 7 + i) + end + + pipeline_popup.border:set_text("top", "Pipeline Status", "center") + state.set_popup_keymaps(pipeline_popup, M.retrigger) + u.switch_can_edit_buf(bufnr, false) + end) + end) +end + +M.retrigger = function() + local body = { pipeline_id = state.INFO.pipeline.id } + if state.INFO.pipeline.status ~= 'failed' then + vim.notify("Pipeline is not in a failed state!", vim.log.levels.WARN) + return + end + + job.run_job("/pipeline", "POST", body, function(data) + vim.notify("Pipeline re-triggered!", vim.log.levels.INFO) + state.INFO.pipeline = data.Pipeline + end) +end + +M.color_status = function(status, bufnr, status_line, linnr) + local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") + vim.cmd(string.format("highlight default StatusHighlight guifg=%s", state.settings.pipeline[status])) + + local status_to_color_map = { + created = 'DiagnosticWarn', + pending = 'DiagnosticWarn', + preparing = 'DiagnosticWarn', + scheduled = 'DiagnosticWarn', + running = 'DiagnosticWarn', + canceled = 'DiagnosticWarn', + skipped = 'DiagnosticWarn', + failed = 'DiagnosticError', + success = 'DiagnosticOK', + } + + vim.api.nvim_buf_set_extmark(bufnr, ns_id, linnr - 1, 0, + { end_row = linnr - 1, end_col = string.len(status_line), hl_group = status_to_color_map[status] }) +end + +return M diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index a024a46..4f52214 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -7,6 +7,7 @@ local discussions = require("gitlab.actions.discussions") local summary = require("gitlab.actions.summary") local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers") local comment = require("gitlab.actions.comment") +local pipeline = require("gitlab.actions.pipeline") local approvals = require("gitlab.actions.approvals") local info = state.dependencies.info @@ -31,6 +32,7 @@ return { create_comment = async.sequence({ info, revisions }, comment.create_comment), create_note = async.sequence({ info }, comment.create_note), review = async.sequence({ u.merge(info, { refresh = true }) }, function() reviewer.open() end), + pipeline = async.sequence({ info }, pipeline.open), -- Discussion Tree Actions 🌴 toggle_discussions = async.sequence({ info }, discussions.toggle), edit_comment = async.sequence({ info }, discussions.edit_comment), diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index cbaf89f..fab95db 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -37,6 +37,17 @@ M.settings = { removed_file = "", } }, + pipeline = { + created = "", + pending = "", + preparing = "", + scheduled = "", + running = "ﰌ", + canceled = "ﰸ", + skipped = "ﰸ", + success = "✓", + failed = "", + }, dialogue = { focus_next = { "j", "", "" }, focus_prev = { "k", "", "" }, diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 37d1e1a..88f9082 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -49,14 +49,18 @@ M.format_date = function(date_string) local time_diff = current_date - date + local function pluralize(num, word) + return num .. string.format(" %s", word) .. (num > 1 and "s" or '') .. " ago" + end + if time_diff < 60 then - return time_diff .. " seconds ago" + return pluralize(time_diff, "second") elseif time_diff < 3600 then - return math.floor(time_diff / 60) .. " minutes ago" + return pluralize(math.floor(time_diff / 60), "minute") elseif time_diff < 86400 then - return math.floor(time_diff / 3600) .. " hours ago" + return pluralize(math.floor(time_diff / 3600), "hour") elseif time_diff < 2592000 then - return math.floor(time_diff / 86400) .. " days ago" + return pluralize(math.floor(time_diff / 86400), "day") else local formatted_date = os.date("%A, %B %e", date) return formatted_date @@ -254,4 +258,17 @@ M.get_win_from_buf = function(bufnr) end end +M.switch_can_edit_buf = function(buf, bool) + vim.api.nvim_buf_set_option(buf, 'modifiable', bool) + vim.api.nvim_buf_set_option(buf, "readonly", not bool) +end + +M.reverse = function(list) + local rev = {} + for i = #list, 1, -1 do + rev[#rev + 1] = list[i] + end + return rev +end + return M