diff --git a/app/widget_maker.go b/app/widget_maker.go index 8e8dae8f..9544d927 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -6,6 +6,7 @@ import ( "github.com/wtfutil/wtf/modules/azuredevops" "github.com/wtfutil/wtf/modules/bamboohr" "github.com/wtfutil/wtf/modules/bargraph" + "github.com/wtfutil/wtf/modules/buildkite" "github.com/wtfutil/wtf/modules/circleci" "github.com/wtfutil/wtf/modules/clocks" "github.com/wtfutil/wtf/modules/cmdrunner" @@ -96,6 +97,9 @@ func MakeWidget( case "blockfolio": settings := blockfolio.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = blockfolio.NewWidget(app, settings) + case "buildkite": + settings := buildkite.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = buildkite.NewWidget(app, pages, settings) case "circleci": settings := circleci.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = circleci.NewWidget(app, settings) diff --git a/modules/buildkite/client.go b/modules/buildkite/client.go new file mode 100644 index 00000000..e375a48f --- /dev/null +++ b/modules/buildkite/client.go @@ -0,0 +1,105 @@ +package buildkite + +import ( + "fmt" + "github.com/wtfutil/wtf/utils" + "net/http" +) + +type Pipeline struct { + Slug string `json:"slug"` +} + +type Build struct { + State string `json:"state"` + Pipeline Pipeline `json:"pipeline"` + Branch string `json:"branch"` + WebUrl string `json:"web_url"` +} + +func (widget *Widget) getBuilds() ([]Build, error) { + builds := []Build{} + + for _, pipeline := range widget.settings.pipelines { + buildsForPipeline, err := widget.recentBuilds(pipeline) + + if err != nil { + return nil, err + } + + mostRecent := mostRecentBuildForBranches(buildsForPipeline, pipeline.branches) + builds = append(builds, mostRecent...) + } + + return builds, nil +} + +func (widget *Widget) recentBuilds(pipeline PipelineSettings) ([]Build, error) { + url := fmt.Sprintf( + "https://api.buildkite.com/v2/organizations/%s/pipelines/%s/builds%s", + widget.settings.orgSlug, + pipeline.slug, + branchesQuery(pipeline.branches), + ) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", widget.settings.token)) + + httpClient := &http.Client{Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }} + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf(resp.Status) + } + + builds := []Build{} + err = utils.ParseJson(&builds, resp.Body) + if err != nil { + return nil, err + } + + return builds, nil +} + +func branchesQuery(branches []string) string { + if len(branches) == 0 { + return "" + } + + if len(branches) == 1 { + return fmt.Sprintf("?branch=%s", branches[0]) + } + + queryString := fmt.Sprintf("?branch[]=%s", branches[0]) + for _, branch := range branches[1:] { + queryString += fmt.Sprintf("&branch[]=%s", branch) + } + + return queryString +} + +func mostRecentBuildForBranches(builds []Build, branches []string) []Build { + recentBuilds := []Build{} + + haveMostRecentBuildForBranch := map[string]bool{} + for _, branch := range branches { + haveMostRecentBuildForBranch[branch] = false + } + + for _, build := range builds { + if !haveMostRecentBuildForBranch[build.Branch] { + haveMostRecentBuildForBranch[build.Branch] = true + recentBuilds = append(recentBuilds, build) + } + } + + return recentBuilds +} diff --git a/modules/buildkite/keyboard.go b/modules/buildkite/keyboard.go new file mode 100644 index 00000000..d4503c7a --- /dev/null +++ b/modules/buildkite/keyboard.go @@ -0,0 +1,5 @@ +package buildkite + +func (widget *Widget) initializeKeyboardControls() { + widget.InitializeCommonControls(widget.Refresh) +} diff --git a/modules/buildkite/settings.go b/modules/buildkite/settings.go new file mode 100644 index 00000000..22f0125c --- /dev/null +++ b/modules/buildkite/settings.go @@ -0,0 +1,54 @@ +package buildkite + +import ( + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" + "github.com/wtfutil/wtf/utils" + "os" +) + +type PipelineSettings struct { + slug string + branches []string +} + +type Settings struct { + common *cfg.Common + token string `help:"Your Buildkite API Token"` + orgSlug string `help:"Organization Slug"` + pipelines []PipelineSettings `help:"An array of pipelines to get data from"` +} + +const defaultTitle = "Buildkite" +const defaultFocusable = true + +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + settings := Settings{ + common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), + token: ymlConfig.UString("token", os.Getenv("WTF_BUILDKITE_TOKEN")), + orgSlug: ymlConfig.UString("organizationSlug"), + pipelines: buildPipelineSettings(ymlConfig), + } + + return &settings +} + +func buildPipelineSettings(ymlConfig *config.Config) []PipelineSettings { + pipelines := []PipelineSettings{} + + for slug, _ := range ymlConfig.UMap("pipelines") { + branches := utils.ToStrs(ymlConfig.UList("pipelines." + slug + ".branches")) + if len(branches) == 0 { + branches = []string{"master"} + } + + pipeline := PipelineSettings{ + slug: slug, + branches: branches, + } + + pipelines = append(pipelines, pipeline) + } + + return pipelines +} diff --git a/modules/buildkite/widget.go b/modules/buildkite/widget.go new file mode 100644 index 00000000..812d5458 --- /dev/null +++ b/modules/buildkite/widget.go @@ -0,0 +1,129 @@ +package buildkite + +import ( + "fmt" + "github.com/rivo/tview" + "github.com/wtfutil/wtf/view" + "strings" +) + +const HelpText = ` + Keyboard commands for Buildkite: +` + +type Widget struct { + view.KeyboardWidget + view.TextWidget + settings *Settings + + builds []Build + err error +} + +func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := Widget{ + KeyboardWidget: view.NewKeyboardWidget(app, pages, settings.common), + TextWidget: view.NewTextWidget(app, settings.common), + settings: settings, + } + + widget.initializeKeyboardControls() + widget.View.SetInputCapture(widget.InputCapture) + widget.View.SetScrollable(true) + widget.KeyboardWidget.SetView(widget.View) + + return &widget +} + +/* -------------------- Exported Functions -------------------- */ + +func (widget *Widget) Refresh() { + builds, err := widget.getBuilds() + + if err != nil { + widget.err = err + widget.builds = nil + } else { + widget.builds = builds + widget.err = nil + } + + // The last call should always be to the display function + widget.display() +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) display() { + widget.Redraw(widget.content) +} + +func (widget *Widget) content() (string, string, bool) { + title := fmt.Sprintf("%s - [green]%s", widget.CommonSettings().Title, widget.settings.orgSlug) + + if widget.err != nil { + return title, widget.err.Error(), true + } + + pipelineData := groupByPipeline(widget.builds) + maxPipelineLength := getLongestPipelineLength(widget.builds) + + str := "" + for pipeline, builds := range pipelineData { + str += fmt.Sprintf("[white]%s", padRight(pipeline, maxPipelineLength)) + for _, build := range builds { + str += fmt.Sprintf(" [%s]%s[white]", buildColor(build.State), build.Branch) + } + str += "\n" + } + + return title, str, false +} + +func groupByPipeline(builds []Build) map[string][]Build { + grouped := make(map[string][]Build) + + for _, build := range builds { + if _, ok := grouped[build.Pipeline.Slug]; ok { + grouped[build.Pipeline.Slug] = append(grouped[build.Pipeline.Slug], build) + } else { + grouped[build.Pipeline.Slug] = []Build{} + grouped[build.Pipeline.Slug] = append(grouped[build.Pipeline.Slug], build) + } + } + + return grouped +} + +func getLongestPipelineLength(builds []Build) int { + maxPipelineLength := 0 + + for _, build := range builds { + if len(build.Pipeline.Slug) > maxPipelineLength { + maxPipelineLength = len(build.Pipeline.Slug) + } + } + + return maxPipelineLength +} + +func padRight(text string, length int) string { + padLength := length - len(text) + + if padLength <= 0 { + return text[:length] + } + + return text + strings.Repeat(" ", padLength) +} + +func buildColor(state string) string { + switch state { + case "passed": + return "green" + case "failed": + return "red" + default: + return "yellow" + } +}