diff --git a/.gitignore b/.gitignore index 6d77695..5046fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .vscode/* -pkg/oldTui +pkg.old/* + diff --git a/cmd/root.go b/cmd/root.go index 55c5046..b6cf0a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,13 +23,11 @@ THE SOFTWARE. package cmd import ( - "context" "fmt" "log" "os" tea "github.com/charmbracelet/bubbletea" - "github.com/clcollins/srepd/pkg/pd" "github.com/clcollins/srepd/pkg/tui" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -37,9 +35,10 @@ import ( const cfgFile = "srepd.yaml" const cfgFilePath = ".config/srepd/" +const defaultEditor = "/usr/bin/vim" -var PagerDutyOauthToken string -var Debug bool +var debug bool +var editor string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ @@ -53,7 +52,7 @@ to be a full-featured PagerDuty client, or kitchen sink, but rather a simple tool to make on-call tasks easier.`, Run: func(cmd *cobra.Command, args []string) { - if Debug { + if debug { for k, v := range viper.GetViper().AllSettings() { if k == "token" { v = "*****" @@ -62,24 +61,22 @@ but rather a simple tool to make on-call tasks easier.`, } } - var ctx = context.Background() - var tuiConfig = &tui.Config{ - Debug: Debug, - Verbose: false, - } - var pdConfig = &pd.Config{} - token := viper.GetString("token") teams := viper.GetStringSlice("teams") silentuser := viper.GetString("silentuser") + ignoreusers := viper.GetStringSlice("ignoreusers") - err := pdConfig.PopulateConfig(ctx, token, teams, silentuser) - if err != nil { - log.Fatal(err) + // The environment variable will always override the config file if set + if editor == "" { + editor = viper.GetString("editor") + if editor == "" { + editor = defaultEditor + } } - p := tea.NewProgram(tui.InitialModel(ctx, tuiConfig, pdConfig)) - _, err = p.Run() + m, _ := tui.InitialModel(token, teams, silentuser, ignoreusers, editor) + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() if err != nil { log.Fatal(err) } @@ -97,8 +94,8 @@ func Execute() { func init() { cobra.OnInitialize(initConfig) - rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "d", false, "Enable debugging output") - viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debugging output") + rootCmd.PersistentFlags().StringVarP(&editor, "editor", "e", "", "Editor to use for notes; default is `$EDITOR` environment variable") } // initConfig reads in config file and ENV variables if set. diff --git a/go.mod b/go.mod index 78e3b20..75cc732 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,41 @@ module github.com/clcollins/srepd -go 1.21 +go 1.21.2 require ( github.com/PagerDuty/go-pagerduty v1.7.0 github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 - github.com/charmbracelet/lipgloss v0.7.1 - github.com/spf13/cobra v1.7.0 + github.com/charmbracelet/glamour v0.6.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.17.0 ) require ( + github.com/alecthomas/chroma v0.10.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/dlclark/regexp2 v1.4.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/gorilla/css v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/microcosm-cc/bluemonday v1.0.21 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.1 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect @@ -39,12 +46,15 @@ require ( github.com/spf13/cast v1.5.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/yuin/goldmark v1.5.2 // indirect + github.com/yuin/goldmark-emoji v1.0.1 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.15.0 // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.12.0 // indirect - golang.org/x/term v0.6.0 // indirect + golang.org/x/term v0.12.0 // indirect golang.org/x/text v0.13.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index daa8ab2..b31b495 100644 --- a/go.sum +++ b/go.sum @@ -40,17 +40,24 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/PagerDuty/go-pagerduty v1.7.0 h1:S1NcMKECxT5hJwV4VT+QzeSsSiv4oWl1s2821dUqG/8= github.com/PagerDuty/go-pagerduty v1.7.0/go.mod h1:PuFyJKRz1liIAH4h5KVXVD18Obpp1ZXRdxHvmGXooro= +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= -github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= -github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= +github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -60,11 +67,13 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -136,6 +145,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -161,13 +172,18 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= +github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= @@ -176,8 +192,11 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= -github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -205,8 +224,8 @@ github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= @@ -228,6 +247,10 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= +github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= +github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -312,6 +335,9 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -368,14 +394,17 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/pd/pd.go b/pkg/pd/pd.go index 8fdf833..d415535 100644 --- a/pkg/pd/pd.go +++ b/pkg/pd/pd.go @@ -8,19 +8,95 @@ import ( ) const ( - pageLimit = 100 - defaultOffset = 0 + defaultPageLimit = 100 + defaultOffset = 0 ) -var pagerDutyDefaultStatuses = []string{"triggered", "acknowledged"} +var defaultIncidentStatues = []string{"triggered", "acknowledged"} type Config struct { Client *pagerduty.Client Teams []*pagerduty.Team SilentUser *pagerduty.User + IgnoreUsers []*pagerduty.User DefaultListOpts *pagerduty.ListIncidentsOptions } +// GetTeamsAsStrings returns a []string of team IDs from the Config +// This is used to populate the pagerduty.ListIncidentsOptions.TeamIDs, etc +func (c *Config) GetTeamsAsStrings() []string { + var s []string + for _, t := range c.Teams { + s = append(s, t.ID) + } + return s +} + +// NewListIncidentAlertsOptsFromDefaults accepts a *Config and returns a pagerduty.ListIncidentAlertsOptions +// with reasonable paging defaults +func NewListIncidentAlertsOptsFromDefaults(c *Config) pagerduty.ListIncidentAlertsOptions { + return pagerduty.ListIncidentAlertsOptions{ + Limit: defaultPageLimit, + Offset: defaultOffset, + Statuses: defaultIncidentStatues, + } +} + +func GetAlerts(ctx context.Context, c *Config, id string) ([]pagerduty.IncidentAlert, error) { + var a []pagerduty.IncidentAlert + + for { + opts := NewListIncidentAlertsOptsFromDefaults(c) + + response, err := c.Client.ListIncidentAlertsWithContext(ctx, id, opts) + if err != nil { + return a, err + } + + a = append(a, response.Alerts...) + + // increment the offset by the page limit to get the next page + opts.Offset += opts.Limit + + if !response.More { + break + } + } + + return a, nil +} + +// GetNotes accepts a context, pdConfig, and incident ID; no options required +// and returns a []pagerduty.IncidentNote +func GetNotes(ctx context.Context, c *Config, id string) ([]pagerduty.IncidentNote, error) { + var n []pagerduty.IncidentNote + n, err := c.Client.ListIncidentNotesWithContext(ctx, id) + if err != nil { + return n, err + } + + return n, nil +} + +func AddNoteToIncident(ctx context.Context, c *Config, id string, user *pagerduty.User, content string) (*pagerduty.IncidentNote, error) { + var n *pagerduty.IncidentNote + + note := pagerduty.IncidentNote{ + Content: content, + User: user.APIObject, + } + + n, err := c.Client.CreateIncidentNoteWithContext(ctx, id, note) + if err != nil { + return n, err + } + + return n, nil +} + +// GetSingleIncident accepts a context, pdConfig, and incident ID +// and returns a single *pagerduty.Incident +// There are no options for this endpoint func GetSingleIncident(ctx context.Context, c *Config, id string) (*pagerduty.Incident, error) { incident, err := c.Client.GetIncidentWithContext(ctx, id) if err != nil { @@ -29,7 +105,22 @@ func GetSingleIncident(ctx context.Context, c *Config, id string) (*pagerduty.In return incident, nil } -func GetIncidents(ctx context.Context, c *Config, opts pagerduty.ListIncidentsOptions) ([]pagerduty.Incident, error) { +// NewListIncidentOptsFromDefaults accepts a *Config and returns a pagerduty.ListIncidentsOptions +// with reasonable defaults for paging, retrieving only triggered and acknowledged incidents, +// and the team IDs from the config +func NewListIncidentOptsFromDefaults(c *Config) pagerduty.ListIncidentsOptions { + return pagerduty.ListIncidentsOptions{ + TeamIDs: c.GetTeamsAsStrings(), + Limit: defaultPageLimit, + Offset: defaultOffset, + Statuses: defaultIncidentStatues, + } +} + +// GetIncidents accepts a context, pdConfig, and pagerduty.ListIncidentsOptions +// We can shoot ourselves in the foot here, if we don't pass reasonable defaults in the opts +func GetIncidents(c *Config, opts pagerduty.ListIncidentsOptions) ([]pagerduty.Incident, error) { + var ctx = context.Background() var i []pagerduty.Incident for { @@ -40,7 +131,8 @@ func GetIncidents(ctx context.Context, c *Config, opts pagerduty.ListIncidentsOp i = append(i, response.Incidents...) - opts.Offset += pageLimit + // increment the offset by the page limit to get the next page + opts.Offset += opts.Limit if !response.More { break @@ -50,29 +142,109 @@ func GetIncidents(ctx context.Context, c *Config, opts pagerduty.ListIncidentsOp return i, nil } -func (c *Config) PopulateConfig(ctx context.Context, token string, teams []string, user string) error { - c.Client = pagerduty.NewOAuthClient(token) +// ReassignIncident accepts a context, pdConfig, valid user email (current user), []pagerduty.Incident, and []pagerduty.User to assign +func ReassignIncident(ctx context.Context, c *Config, email string, incidents []pagerduty.Incident, users []*pagerduty.User) ([]pagerduty.Incident, error) { + var i []pagerduty.Incident - c.DefaultListOpts = &pagerduty.ListIncidentsOptions{ - TeamIDs: teams, - Limit: pageLimit, - Offset: defaultOffset, - Statuses: pagerDutyDefaultStatuses, + a := []pagerduty.Assignee{} + for _, user := range users { + a = append(a, pagerduty.Assignee{Assignee: user.APIObject}) } + manageOpts := []pagerduty.ManageIncidentsOptions{} + for _, incident := range incidents { + manageOpts = append(manageOpts, pagerduty.ManageIncidentsOptions{ + ID: incident.ID, + Assignments: a, + }) + } + + // This loop is likely unnecessary, as the "More" response is probably not used by PagerDuty here + // but I'm including it in case we need to use it in the future, and raising a panic if we receive + // a "More" response so we can fix the code + for { + response, err := c.Client.ManageIncidentsWithContext(ctx, email, manageOpts) + if err != nil { + return i, err + } + + i = append(i, response.Incidents...) + + // I don't think we'll ever get "More", but if so, we need to panic and raise the issue it so we can fix the code + if response.More { + panic("(reassignIncident): received more than one page of incidents, but there is no paging option") + } + + // This should still work to break out of the loop + if !response.More { + break + } + } + + return i, nil +} + +func AcknowledgeIncident(ctx context.Context, c *Config, email string, incidents []pagerduty.Incident) ([]pagerduty.Incident, error) { + var i []pagerduty.Incident + + manageOpts := []pagerduty.ManageIncidentsOptions{} + for _, incident := range incidents { + manageOpts = append(manageOpts, pagerduty.ManageIncidentsOptions{ + ID: incident.ID, + Status: "acknowledged", + }) + } + + for { + response, err := c.Client.ManageIncidentsWithContext(ctx, email, manageOpts) + if err != nil { + return i, err + } + + i = append(i, response.Incidents...) + + // I don't think we'll ever get "More", but if so, we need to panic and raise the issue it so we can fix the code + if response.More { + panic("(acknowledgeIncident): received more than one page of incidents, but there is no paging option") + } + + // This should still work to break out of the loop + if !response.More { + break + } + + } + + return i, nil +} + +// PopulateConfig generates a pagerduty client, and populates the teams and silent user in the *Config struct +func NewConfig(token string, teams []string, user string, ignoreusers []string) (*Config, error) { + ctx := context.Background() + c := &Config{} + c.Client = pagerduty.NewOAuthClient(token) + for _, i := range teams { team, err := c.Client.GetTeamWithContext(ctx, i) if err != nil { - return fmt.Errorf("failed to find PagerDuty team `%v`: %v", i, err) + return c, fmt.Errorf("failed to find PagerDuty team `%v`: %v", i, err) } c.Teams = append(c.Teams, team) } silentuser, err := c.Client.GetUserWithContext(ctx, user, pagerduty.GetUserOptions{}) if err != nil { - return fmt.Errorf("failed to find PagerDuty user for silencing alerts `%v`: %v", user, err) + return c, fmt.Errorf("failed to find PagerDuty user for silencing alerts `%v`: %v", user, err) } c.SilentUser = silentuser - return nil + for _, i := range ignoreusers { + ignoreuser, err := c.Client.GetUserWithContext(ctx, i, pagerduty.GetUserOptions{}) + if err != nil { + return c, fmt.Errorf("failed to find PagerDuty user to ignore `%v`: %v", i, err) + } + c.IgnoreUsers = append(c.IgnoreUsers, ignoreuser) + } + + return c, nil } diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go index 64ffcea..e22e73d 100644 --- a/pkg/tui/commands.go +++ b/pkg/tui/commands.go @@ -2,39 +2,96 @@ package tui import ( "context" + "errors" + "log" + "os" + "os/exec" "github.com/PagerDuty/go-pagerduty" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" "github.com/clcollins/srepd/pkg/pd" ) -var logLevel = "warn" +type updateIncidentListMsg string +type updatedIncidentListMsg struct { + incidents []pagerduty.Incident + err error +} -type getCurrentUserMsg string -type gotCurrentUserMsg *pagerduty.User +func updateIncidentList(p *pd.Config) tea.Cmd { + return func() tea.Msg { + opts := pd.NewListIncidentOptsFromDefaults(p) + i, err := pd.GetIncidents(p, opts) + return updatedIncidentListMsg{i, err} + } +} -func getCurrentUser(ctx context.Context, pdConfig *pd.Config) tea.Cmd { - debug("getCurrentUser") +type getIncidentMsg string +type gotIncidentMsg struct { + incident *pagerduty.Incident + err error +} + +func getIncident(p *pd.Config, id string) tea.Cmd { return func() tea.Msg { - u, err := pdConfig.Client.GetCurrentUserWithContext(ctx, pagerduty.GetCurrentUserOptions{}) - if err != nil { - return errMsg{err} - } - return gotCurrentUserMsg(u) + ctx := context.Background() + i, err := p.Client.GetIncidentWithContext(ctx, id) + return gotIncidentMsg{i, err} } } -type getSilentUserMsg string -type gotSilentUserMsg *pagerduty.User +type gotIncidentAlertsMsg struct { + alerts []pagerduty.IncidentAlert + err error +} -func getUser(ctx context.Context, pdConfig *pd.Config, id string) tea.Cmd { +func getIncidentAlerts(p *pd.Config, id string) tea.Cmd { return func() tea.Msg { - u, err := pdConfig.Client.GetUserWithContext(ctx, id, pagerduty.GetUserOptions{}) - if err != nil { - return errMsg{err} + ctx := context.Background() + a, err := pd.GetAlerts(ctx, p, id) + return gotIncidentAlertsMsg{a, err} + } +} + +type gotIncidentNotesMsg struct { + notes []pagerduty.IncidentNote + err error +} + +func getIncidentNotes(p *pd.Config, id string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + n, err := pd.GetNotes(ctx, p, id) + return gotIncidentNotesMsg{n, err} + } +} + +type getCurrentUserMsg string +type gotCurrentUserMsg struct { + user *pagerduty.User + err error +} + +func getCurrentUser(pdConfig *pd.Config) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + u, err := pdConfig.Client.GetCurrentUserWithContext(ctx, pagerduty.GetCurrentUserOptions{}) + return gotCurrentUserMsg{u, err} + } +} + +func AssignedToAnyUsers(i pagerduty.Incident, ids []string) bool { + for _, a := range i.Assignments { + for _, id := range ids { + if a.Assignee.ID == id { + return true + } } - return gotSilentUserMsg(u) } + return false } func AssignedToUser(i pagerduty.Incident, id string) bool { @@ -46,38 +103,124 @@ func AssignedToUser(i pagerduty.Incident, id string) bool { return false } -type getIncidentsMsg string -type gotIncidentsMsg []pagerduty.Incident +type editorFinishedMsg struct { + err error + file *os.File +} -func getIncidents(ctx context.Context, pdConfig *pd.Config) tea.Cmd { - debug("getIncidents") - return func() tea.Msg { - opts := pagerduty.ListIncidentsOptions{ - TeamIDs: pdConfig.DefaultListOpts.TeamIDs, - Limit: pdConfig.DefaultListOpts.Limit, - Offset: pdConfig.DefaultListOpts.Offset, - Statuses: pdConfig.DefaultListOpts.Statuses, +var defaultEditor = "/usr/bin/vim" + +func openEditorCmd(editor string) tea.Cmd { + file, err := os.CreateTemp(os.TempDir(), "") + if err != nil { + return func() tea.Msg { + return errMsg{error: err} } + } + c := exec.Command(editor, file.Name()) + return tea.ExecProcess(c, func(err error) tea.Msg { + return editorFinishedMsg{err, file} + }) +} + +func newIncidentViewer(content string) viewport.Model { + + vp := viewport.New(windowSize.Width, windowSize.Height-5) + vp.MouseWheelEnabled = true + vp.Style = lipgloss.NewStyle(). + Width(windowSize.Width - 10). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + PaddingRight(2) + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(windowSize.Width), + ) + if err != nil { + log.Fatal(err) + } + + str, err := renderer.Render(content) + if err != nil { + log.Fatal(err) + } - i, err := pd.GetIncidents(ctx, pdConfig, opts) + vp.SetContent(str) + return vp +} + +type acknowledgeIncidentsMsg struct { + incidents []pagerduty.Incident +} +type acknowledgedIncidentsMsg struct { + incidents []pagerduty.Incident + err error +} +type waitForSelectedIncidentsThenAcknowledgeMsg string + +func acknowledgeIncidents(pdConfig *pd.Config, email string, i []pagerduty.Incident) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + a, err := pd.AcknowledgeIncident(ctx, pdConfig, email, i) + return acknowledgedIncidentsMsg{a, err} + } +} + +type reassignIncidentsMsg struct { + incidents []pagerduty.Incident + users []*pagerduty.User +} +type reassignedIncidentsMsg []pagerduty.Incident + +// reassignIncident accepts a context, pdConfig, currentUser email, incident ID, and a []pagerduty.User to assign +// and returns a "reassignedIncidentMsg" tea.Msg with the incident ID as a string +func reassignIncidents(pdConfig *pd.Config, email string, i []pagerduty.Incident, u []*pagerduty.User) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + r, err := pd.ReassignIncident(ctx, pdConfig, email, i, u) if err != nil { return errMsg{err} } - return gotIncidentsMsg(i) + return reassignedIncidentsMsg(r) + } +} + +type silenceIncidentsMsg []pagerduty.Incident +type waitForSelectedIncidentsThenSilenceMsg string + +var errSilenceIncidentInvalidArgs = errors.New("silenceIncidents: invalid arguments") + +// Silence incidents accepts a []pagerduty.Incident, and []pagerduty.User to assign +func silenceIncidents(i []pagerduty.Incident, u []*pagerduty.User) tea.Cmd { + return func() tea.Msg { + if len(i) == 0 || len(u) == 0 { + return errMsg{errSilenceIncidentInvalidArgs} + } + return reassignIncidentsMsg{i, u} } } -type getSingleIncidentMsg string -type gotSingleIncidentMsg pagerduty.Incident +type clearSelectedIncidentsMsg string + +type addIncidentNoteMsg string +type addedIncidentNoteMsg struct { + note *pagerduty.IncidentNote + err error +} -func getSingleIncident(ctx context.Context, pdConfig *pd.Config, id string) tea.Cmd { +func addNoteToIncident(pdConfig *pd.Config, id string, user *pagerduty.User, content *os.File) tea.Cmd { return func() tea.Msg { - i, err := pd.GetSingleIncident(ctx, pdConfig, id) + defer content.Close() + + ctx := context.Background() + + bytes, err := os.ReadFile(content.Name()) if err != nil { return errMsg{err} } + content := string(bytes[:]) - return gotSingleIncidentMsg(*i) - + n, err := pd.AddNoteToIncident(ctx, pdConfig, id, user, content) + return addedIncidentNoteMsg{n, err} } } diff --git a/pkg/tui/keymap.go b/pkg/tui/keymap.go index 430bf1b..fc13e82 100644 --- a/pkg/tui/keymap.go +++ b/pkg/tui/keymap.go @@ -2,42 +2,42 @@ package tui import "github.com/charmbracelet/bubbles/key" -type KeyMap struct { - Up key.Binding - Down key.Binding - Quit key.Binding - Help key.Binding - Back key.Binding - Refresh key.Binding - Enter key.Binding - Team key.Binding - Silence key.Binding - Ack key.Binding - Escalate key.Binding +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{k.Help, k.Quit} } -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Enter, k.Quit, k.Help} -} - -func (k KeyMap) FullHelp() [][]key.Binding { +func (k keymap) FullHelp() [][]key.Binding { // TODO: Return a pop-over window here instead return [][]key.Binding{ // Each slice here is a column in the help window - {k.Up, k.Down}, - {k.Enter, k.Back}, + {k.Up, k.Down, k.Enter, k.Back}, + {k.Team, k.Refresh, k.Ack, k.Silence}, {k.Quit, k.Help}, } } -var defaultKeyMap = KeyMap{ +type keymap struct { + Up key.Binding + Down key.Binding + Back key.Binding + Enter key.Binding + Quit key.Binding + Help key.Binding + Team key.Binding + Refresh key.Binding + Note key.Binding + Silence key.Binding + Ack key.Binding +} + +var defaultKeyMap = keymap{ Up: key.NewBinding( key.WithKeys("k", "up"), - key.WithHelp(upArrow+"/k", "up"), + key.WithHelp("↑/k", "up"), ), Down: key.NewBinding( key.WithKeys("j", "down"), - key.WithHelp(downArrow+"/j", "down"), + key.WithHelp("↓/j", "down"), ), Quit: key.NewBinding( key.WithKeys("q", "ctrl+c"), @@ -51,10 +51,6 @@ var defaultKeyMap = KeyMap{ key.WithKeys("esc"), key.WithHelp("esc", "back"), ), - Refresh: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "refresh"), - ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "select"), @@ -63,6 +59,14 @@ var defaultKeyMap = KeyMap{ key.WithKeys("t"), key.WithHelp("t", "toggle team/individual"), ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + Note: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "add [n]ote"), + ), Silence: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "silence"), @@ -71,8 +75,4 @@ var defaultKeyMap = KeyMap{ key.WithKeys("a"), key.WithHelp("a", "acknowledge"), ), - Escalate: key.NewBinding( - key.WithKeys("u"), - key.WithHelp("u", "un-acknowledge"), - ), } diff --git a/pkg/tui/table.go b/pkg/tui/table.go index 15f2fa5..74b38d6 100644 --- a/pkg/tui/table.go +++ b/pkg/tui/table.go @@ -1,70 +1,20 @@ package tui -import ( - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) +import "github.com/charmbracelet/bubbles/table" -// TODO: Figure out how to dynamically resize the table and columns based on terminal size -// and resize tea.Msg events received from the terminal const ( - // Standard terminal size is 80x24 - // so we have 70 columns to work with after subtracting for the table borders (2) - // and 2 for each column's cellpadding (8) - // and 2 more for the terminal scroll bars - // For rows: 20 subtracting table borders, status messages, and help - initialTableWidth = dotWidth + idWidth + summaryWidth + clusterIDWidth - scrollbars - initialTableHeight = 18 - dotWidth = 2 - idWidth = 16 // Looks like most PD alerts are 14 characters - summaryWidth = 36 - clusterIDWidth = 16 // ClusterID and UUIDs are 32 characters; Display Names tend to be ~16 - - // Basic stuff (subtract 2 for scroll bars) - defaultTerminalWidth = 80 - scrollbars - scrollbars = 2 - initialViewPortWidth = defaultTerminalWidth + idColumnWidth = 16 + defaultColumnWidth = 32 ) var ( - incidentListTableColumns = []table.Column{ + incidentViewColumns = []table.Column{ // Currently the dot column is not used // but may be useful for selecting multiple incidents - {Title: dot, Width: dotWidth}, - {Title: "ID", Width: idWidth}, - {Title: "Summary", Width: summaryWidth}, - {Title: "ClusterID", Width: clusterIDWidth}, + // TODO: Figure out some way to update these columns on resize + {Title: dot, Width: 1}, + {Title: "ID", Width: idColumnWidth}, + {Title: "Summary"}, + {Title: "ClusterID"}, } ) - -type createTableWithStylesMsg string -type createdTableWithStylesMsg struct { - // Not sure this is the right way to do this - // Why can I not just pass the table.Model? - // Because it's a struct? - table table.Model -} - -func createTableWithStyles() tea.Cmd { - return func() tea.Msg { - t := table.New( - table.WithColumns(incidentListTableColumns), - table.WithRows([]table.Row{}), - table.WithFocused(true), - table.WithHeight(initialTableHeight), - ) - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - t.SetStyles(s) - return createdTableWithStylesMsg(createdTableWithStylesMsg{table: t}) - } -} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 066b559..f1373cc 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -1,243 +1,387 @@ package tui import ( - "context" "fmt" "log" + "time" "github.com/PagerDuty/go-pagerduty" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/clcollins/srepd/pkg/pd" ) const ( - dot = "•" - upArrow = "↑" - downArrow = "↓" - refreshLogMessage = "refreshing..." - gettingUserMessage = "getting user info..." - gettingSilentUserMsg = "getting 'Silent' user info..." + gettingUserStatus = "getting user info..." + loadingIncidentsStatus = "loading incidents..." ) -type Config struct { - Debug bool - Verbose bool -} +type errMsg struct{ error } + +type Model struct { + config *pd.Config + editor string + + currentUser *pagerduty.User + + table table.Model + input textinput.Model + incidentViewer viewport.Model + help help.Model + + status string -var errorLog []error - -// Type and function for capturing error messages with tea.Msg -type errMsg struct{ err error } - -func (e errMsg) Error() string { return e.err.Error() } - -type model struct { - help help.Model - cliConfig *Config - pdConfig *pd.Config - context context.Context - currentUser *pagerduty.User - incidentList []pagerduty.Incident - selectedIncident *pagerduty.Incident - table table.Model - toggleCurrentUserOnly bool - statusMessage string - // Not Implemented - // debugMessage string - // confirm bool + incidentList []pagerduty.Incident + selectedIncident *pagerduty.Incident + selectedIncidentNotes []pagerduty.IncidentNote + selectedIncidentAlerts []pagerduty.IncidentAlert + + teamMode bool } -func InitialModel(ctx context.Context, config *Config, pdConfig *pd.Config) model { - return model{ - help: help.New(), - context: ctx, - cliConfig: config, - pdConfig: pdConfig, +func InitialModel(token string, teams []string, user string, ignoreusers []string, editor string) (tea.Model, tea.Cmd) { + var err error + + input := textinput.New() + input.Prompt = " $ " + input.CharLimit = 32 + input.Width = 50 + + m := Model{ + editor: editor, + help: help.New(), + table: table.New( + table.WithFocused(true), + ), + input: input, + status: loadingIncidentsStatus, + teamMode: false, } -} -func (m model) Init() tea.Cmd { - if m.cliConfig.Debug { - logLevel = "debug" + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + m.table.SetStyles(s) + + // This is an ugly way to handle this error + pd, err := pd.NewConfig(token, teams, user, ignoreusers) + m.config = pd + + return m, func() tea.Msg { + return errMsg{err} } +} +func (m Model) Init() tea.Cmd { return tea.Batch( - func() tea.Msg { return tea.ClearScreen() }, - func() tea.Msg { return createTableWithStylesMsg("create table") }, - func() tea.Msg { return getCurrentUserMsg("get user") }, - // Currently get silent user during root cmd startup; maybe better here? - // func() tea.Msg { return getSilentUserMsg("get silent user") }, - func() tea.Msg { return getIncidentsMsg("get incidents") }, + updateIncidentList(m.config), + getCurrentUser(m.config), ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - debug(fmt.Sprintf("Update: %s", msg)) +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: - // There's a couple of things that need to update on resize - // Gotta figure out how to do that - return m, nil + windowSize = msg + top, _, bottom, _ := mainStyle.GetMargin() + eighthWindow := windowSize.Width / 8 + cellPadding := (horizontalPadding * 2) * 4 + borderEdges := 2 + 10 - case tea.KeyMsg: - switch { + m.help.Width = windowSize.Width - borderEdges + + m.table.SetColumns([]table.Column{ + {Title: dot, Width: 1}, + {Title: "ID", Width: eighthWindow + cellPadding - borderEdges}, + {Title: "Summary", Width: eighthWindow * 3}, + {Title: "ClusterID", Width: eighthWindow * 3}, + }) + + height := windowSize.Height - top - bottom - 10 - // Pressing "q" or "ctrl-c" quits the program - case key.Matches(msg, defaultKeyMap.Quit): + m.table.SetHeight(height) + + case tea.KeyMsg: + // Always allow for quitting with q or Esc no matter what view is focused + if key.Matches(msg, defaultKeyMap.Quit) { return m, tea.Quit + } - // Pressing "h" shows the full help message - case key.Matches(msg, defaultKeyMap.Help): - m.help.ShowAll = !m.help.ShowAll - return m, nil + if m.selectedIncident != nil { + switch { + case key.Matches(msg, defaultKeyMap.Up): + case key.Matches(msg, defaultKeyMap.Down): + case key.Matches(msg, defaultKeyMap.Back): + m.status = "" + m.selectedIncident = nil + m.selectedIncidentAlerts = nil + m.selectedIncidentNotes = nil + } - // Pressing "f" refreshes the incident list from PagerDuty - case key.Matches(msg, defaultKeyMap.Refresh): - m.statusMessage = refreshLogMessage - return m, getIncidents(m.context, m.pdConfig) - - // Up and down arrows, and j/k keys move the cursor - case key.Matches(msg, defaultKeyMap.Up): - m.table.MoveUp(1) - - case key.Matches(msg, defaultKeyMap.Down): - m.table.MoveDown(1) - - // Pressing "Enter" selects the incident to view - // Get the incident id from the selected row, set the status message, - // and call "getSingleIncident" to get the incident details - case key.Matches(msg, defaultKeyMap.Enter): - i := m.table.SelectedRow()[1] - m.statusMessage = fmt.Sprintf("getting incident %s", i) - return m, getSingleIncident(m.context, m.pdConfig, i) - - // Pressing "Esc" clears the selected incident - case key.Matches(msg, defaultKeyMap.Back): - m.selectedIncident = nil - return m, nil + } - // Toggle team view vs. current user view - // And call "gotIncidentsMsg" with the unfiltered list of incidents - // to rebuild the table base on the current criteria - case key.Matches(msg, defaultKeyMap.Team): - m.toggleCurrentUserOnly = !m.toggleCurrentUserOnly - // pass the unfiltered incident list stored in the model - return m, func() tea.Msg { return gotIncidentsMsg(m.incidentList) } + // Key map behavior based on what view is currently focused + if m.input.Focused() { + // Command for focused "input" textarea - case key.Matches(msg, defaultKeyMap.Silence): - // not implemented - return m, nil - case key.Matches(msg, defaultKeyMap.Ack): - // not implemented - return m, nil - case key.Matches(msg, defaultKeyMap.Escalate): - // not implemented + switch { + case key.Matches(msg, defaultKeyMap.Enter): + // TODO: SAVE INPUT TO VARIABLE HERE WHEN ENTER IS PRESSED + m.input.SetValue("") + m.input.Blur() + + case key.Matches(msg, defaultKeyMap.Back): + m.input.SetValue("") + m.input.Blur() + } + + m.input, cmd = m.input.Update(msg) + cmds = append(cmds, cmd) + + } else { + + // Default commands for the table view + switch { + + // case SOME KEY FOR INPUT MODE: + // m.input.Focus() + // m.input.SetValue("View Incident by ID") + // m.input.Blink() + + case key.Matches(msg, defaultKeyMap.Help): + m.help.ShowAll = !m.help.ShowAll + + case key.Matches(msg, defaultKeyMap.Up): + m.table.MoveUp(1) + m.incidentViewer.LineUp(1) + + case key.Matches(msg, defaultKeyMap.Down): + m.table.MoveDown(1) + m.incidentViewer.LineDown(1) + + case key.Matches(msg, defaultKeyMap.Enter): + if m.table.SelectedRow() == nil { + m.status = "no incident selected" + return m, nil + } + cmds = append(cmds, + getIncident(m.config, m.table.SelectedRow()[1]), + getIncidentAlerts(m.config, m.table.SelectedRow()[1]), + getIncidentNotes(m.config, m.table.SelectedRow()[1]), + ) + + case key.Matches(msg, defaultKeyMap.Back): + m.status = "" + m.selectedIncident = nil + m.selectedIncidentAlerts = nil + m.selectedIncidentNotes = nil + + case key.Matches(msg, defaultKeyMap.Team): + m.teamMode = !m.teamMode + cmds = append(cmds, func() tea.Msg { return updatedIncidentListMsg{m.incidentList, nil} }) + + case key.Matches(msg, defaultKeyMap.Refresh): + m.status = loadingIncidentsStatus + cmds = append(cmds, updateIncidentList(m.config)) + + case key.Matches(msg, defaultKeyMap.Note): + cmds = append(cmds, openEditorCmd(m.editor)) + + case key.Matches(msg, defaultKeyMap.Silence): + if m.selectedIncident == nil { + return m, tea.Sequence( + // These fire in sequence, but because they're messages, they don't wait for the previous one to finish + // So we need some way of telling the system to poll + func() tea.Msg { return getIncidentMsg(m.table.SelectedRow()[1]) }, + func() tea.Msg { return waitForSelectedIncidentsThenSilenceMsg("wait") }, + ) + } else { + return m, func() tea.Msg { return silenceIncidentsMsg([]pagerduty.Incident{*m.selectedIncident}) } + } + + case key.Matches(msg, defaultKeyMap.Ack): + if m.selectedIncident == nil { + return m, tea.Sequence( + // These fire in sequence, but because they're messages, they don't wait for the previous one to finish + // So we need some way of telling the system to poll + func() tea.Msg { return getIncidentMsg(m.table.SelectedRow()[1]) }, + func() tea.Msg { return waitForSelectedIncidentsThenAcknowledgeMsg("wait") }, + ) + } else { + return m, func() tea.Msg { return acknowledgeIncidentsMsg{incidents: []pagerduty.Incident{*m.selectedIncident}} } + } + } + } + + // Command to get an incident by ID + case getIncidentMsg: + m.status = fmt.Sprintf("getting incident %s...", msg) + cmds = append(cmds, getIncident(m.config, string(msg))) + + // Set the selected incident to the incident returned from the getIncident command + case gotIncidentMsg: + if msg.err != nil { + log.Fatal(msg.err) + m.status = msg.err.Error() return m, nil - default: + } + + m.status = fmt.Sprintf("got incident %s", msg.incident.ID) + m.selectedIncident = msg.incident + + case gotIncidentNotesMsg: + if msg.err != nil { + log.Fatal(msg.err) + m.status = msg.err.Error() return m, nil } - // Receipt of an errMsg - // Set the status message to the error message and append the error to the error log - case errMsg: - m.statusMessage = "ERROR: " + msg.Error() - errorLog = append(errorLog, msg.err) - return m, nil + // CANNOT refer to the m.SelectedIncident, because it may not have + // completed yet, and will be nil + m.status = fmt.Sprintf("got %d notes for incident", len(msg.notes)) + m.selectedIncidentNotes = msg.notes - // Command to create a table with styles - // Not sure if this is the best way to do this - maybe should just live in init - case createTableWithStylesMsg: - return m, createTableWithStyles() + case gotIncidentAlertsMsg: + if msg.err != nil { + log.Fatal(msg.err) + m.status = msg.err.Error() + return m, nil + } - // Set the table model to the table created in the createTableWithStyles command - case createdTableWithStylesMsg: - m.table = msg.table - return m, nil + // CANNOT refer to the m.SelectedIncident, because it may not have + // completed yet, and will be nil + m.status = fmt.Sprintf("got %d alerts for incident", len(msg.alerts)) + m.selectedIncidentAlerts = msg.alerts // Command to get the current user case getCurrentUserMsg: - m.statusMessage = gettingUserMessage - return m, getCurrentUser(m.context, m.pdConfig) + m.status = gettingUserStatus + cmds = append(cmds, getCurrentUser(m.config)) // Set the current user to the user returned from the getCurrentUser command case gotCurrentUserMsg: - m.currentUser = msg - return m, nil - - // Just validate the silent user exists - case getSilentUserMsg: - m.statusMessage = gettingSilentUserMsg - return m, getUser(m.context, m.pdConfig, m.pdConfig.SilentUser.ID) - - // Do nothing; this is just a placeholder for the future if necessary - case gotSilentUserMsg: - return m, nil - - // Command to retrieve a single incident - // Nothing currently sends this message (see the "enter" case above, which sends the command) - // This is just included for completeness - // case getSingleIncidentMsg: - // return m, getSingleIncident(m.context, m.pdConfig, ) - - // Set the selected incident to the incident returned from the getSingleIncident command - case gotSingleIncidentMsg: - debug("gotSingleIncidentMsg") - m.statusMessage = fmt.Sprintf("got incident %s", msg.ID) - i := pagerduty.Incident(msg) - m.selectedIncident = &i - return m, nil + m.currentUser = msg.user + if msg.err != nil { + log.Fatal(msg.err) + m.status = msg.err.Error() + return m, nil + } + m.status = fmt.Sprintf("got user %s", m.currentUser.Email) - // Command to retrieve the list of incidents from PagerDuty - case getIncidentsMsg: - return m, getIncidents(m.context, m.pdConfig) + // Nothing directly calls this yet + case updateIncidentListMsg: + m.status = loadingIncidentsStatus + cmds = append(cmds, updateIncidentList(m.config)) - // Set the incident list to the list returned from the getIncidents command - case gotIncidentsMsg: - m.incidentList = msg + case updatedIncidentListMsg: + if msg.err != nil { + log.Fatal(msg.err) + m.status = msg.err.Error() + return m, nil + } + m.incidentList = msg.incidents var rows []table.Row - m.table.SetRows(rows) - for _, p := range m.incidentList { - // If toggleCurrentUserOnly is true, rebuild the table from the incident list - // including only the incidents assigned to the current user - if m.toggleCurrentUserOnly { - if AssignedToUser(p, m.currentUser.ID) { - rows = append(rows, table.Row{"", p.ID, p.Title, p.Service.Summary}) + + var ignoreUsersList []string + for _, i := range m.config.IgnoreUsers { + ignoreUsersList = append(ignoreUsersList, i.ID) + } + for _, i := range msg.incidents { + if m.teamMode { + if !AssignedToAnyUsers(i, ignoreUsersList) { + rows = append(rows, table.Row{"", i.ID, i.Title, i.Service.Summary}) } } else { - // Otherwise rebuild the table from the incident list, excluding the incidents - // assigned to the silent user - we never want to see those - if !AssignedToUser(p, m.pdConfig.SilentUser.ID) { - rows = append(rows, table.Row{"", p.ID, p.Title, p.Service.Summary}) + if AssignedToUser(i, m.currentUser.ID) { + rows = append(rows, table.Row{"", i.ID, i.Title, i.Service.Summary}) } } } - m.statusMessage = fmt.Sprintf("got %d incidents", len(rows)) m.table.SetRows(rows) - return m, nil + if len(msg.incidents) == 1 { + m.status = fmt.Sprintf("retrieved %d incident...", len(m.table.Rows())) + } else { + m.status = fmt.Sprintf("retrieved %d incidents...", len(m.table.Rows())) + } + + case editorFinishedMsg: + if msg.err != nil { + log.Fatal(msg.err) + m.status = msg.err.Error() + return m, nil + } + + cmds = append(cmds, addNoteToIncident(m.config, m.selectedIncident.ID, m.currentUser, msg.file)) + + case waitForSelectedIncidentsThenAcknowledgeMsg: + if m.selectedIncident == nil { + time.Sleep(time.Second * 1) + m.status = "waiting for incident info..." + return m, func() tea.Msg { return waitForSelectedIncidentsThenAcknowledgeMsg(msg) } + } + return m, func() tea.Msg { return acknowledgeIncidentsMsg{incidents: []pagerduty.Incident{*m.selectedIncident}} } + + case reassignIncidentsMsg: + return m, reassignIncidents(m.config, m.currentUser.Email, msg.incidents, msg.users) + + case reassignedIncidentsMsg: + m.status = fmt.Sprintf("reassigned incidents %v; refreshing Incident List ", msg) + return m, func() tea.Msg { return updateIncidentListMsg("get incidents") } + + case silenceIncidentsMsg: + var incidents []pagerduty.Incident = msg + var users []*pagerduty.User + incidents = append(incidents, *m.selectedIncident) + users = append(users, m.config.SilentUser) + return m, tea.Sequence( + silenceIncidents(incidents, users), + func() tea.Msg { return clearSelectedIncidentsMsg("clear incidents") }, + ) + + case waitForSelectedIncidentsThenSilenceMsg: + if m.selectedIncident == nil { + time.Sleep(time.Second * 1) + m.status = "waiting for incident info..." + return m, func() tea.Msg { return waitForSelectedIncidentsThenSilenceMsg(msg) } + } + return m, func() tea.Msg { return silenceIncidentsMsg([]pagerduty.Incident{*m.selectedIncident}) } - // Do nothing - default: + case clearSelectedIncidentsMsg: + m.selectedIncident = nil + m.selectedIncidentNotes = nil + m.selectedIncidentAlerts = nil return m, nil } - return m, nil + return m, tea.Batch(cmds...) + } -func (m model) View() string { - debug("View") +func (m Model) View() string { + helpView := helpStyle.Render(m.help.View(defaultKeyMap)) + if m.selectedIncident != nil { - return m.renderIncidentView() + m.incidentViewer = newIncidentViewer(m.template()) + return mainStyle.Render(m.renderHeader() + "\n" + m.incidentViewer.View() + "\n" + helpView) } - return m.renderIncidentTable() -} - -func debug(s string) { - if logLevel == "debug" { - log.Print(s) + if m.input.Focused() { + return mainStyle.Render(m.renderHeader() + "\n" + tableStyle.Render(m.table.View()) + "\n" + m.input.View() + "\n" + helpView) } + return mainStyle.Render(m.renderHeader() + "\n" + tableStyle.Render(m.table.View()) + "\n" + helpView) } diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 3f27dc3..9df5996 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -1,77 +1,68 @@ package tui import ( + "bytes" "fmt" + "html/template" + "log" "strings" - "github.com/charmbracelet/bubbles/table" + "github.com/PagerDuty/go-pagerduty" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -func (m model) renderIncidentView() string { - var s strings.Builder - - s.WriteString(renderStatusArea(m.statusMessage)) - s.WriteString(fmt.Sprintf("%s\n", m.selectedIncident.Title)) - s.WriteString(renderHelpArea(m.help.View(defaultKeyMap))) +const ( + dot = "•" + upArrow = "↑" + downArrow = "↓" +) - return s.String() -} +var ( + windowSize tea.WindowSizeMsg + horizontalPadding = 1 + borderWidth = 1 + mainStyle = lipgloss.NewStyle().Margin(0, 0).Padding(0, horizontalPadding) + assigneeStyle = mainStyle.Copy() + statusStyle = mainStyle.Copy() + assignedStringWidth = len("Assigned to User") + (horizontalPadding * 2 * 2) + (borderWidth * 2 * 2) + 10 + tableStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("105")) +) -func (m model) renderIncidentTable() string { +func (m Model) renderHeader() string { var s strings.Builder - //var assignedTo string + var assignedTo string - //assignedTo = "Team" + assignedTo = "User" - if m.toggleCurrentUserOnly { - //assignedTo = m.currentUser.Name + if m.teamMode { + assignedTo = "Team" } - //s.WriteString(renderAssigneeArea(assignedTo)) - s.WriteString(renderStatusArea(m.statusMessage)) - s.WriteString(renderTableArea(m.table)) - s.WriteString(renderHelpArea(m.help.View(defaultKeyMap))) + s.WriteString( + lipgloss.JoinHorizontal( + 0.2, + statusStyle.Width(windowSize.Width-assignedStringWidth).Render(statusArea(m.status)), + assigneeStyle.Render(assigneeArea(assignedTo)), + ), + ) return s.String() } -func renderAssigneeArea(s string) string { - // Gotta figure out how to accurately update the width on screen resize - var style = lipgloss.NewStyle(). - Width(initialTableWidth). - Height(1). - Align(lipgloss.Right, lipgloss.Bottom). - BorderStyle(lipgloss.HiddenBorder()) - - var fstring = "Assigned to %s" +func assigneeArea(s string) string { + var fstring = "Assigned to " + s fstring = strings.TrimSuffix(fstring, "\n") - return style.Render(fmt.Sprintf(fstring, s)) -} -func renderTableArea(t table.Model) string { - var style = lipgloss.NewStyle(). - MarginTop(1). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - return style.Render(t.View()) + return fstring } -func renderStatusArea(s string) string { - var style = lipgloss.NewStyle(). - Width(initialViewPortWidth). - Align(lipgloss.Left). - // No top/bottom padding - Padding(0, 1). - Bold(false) - +func statusArea(s string) string { var fstring = "> %s" fstring = strings.TrimSuffix(fstring, "\n") - maxTextWidth := style.GetWidth() - style.GetHorizontalPadding() - style.GetHorizontalMargins() - fstring = truncateStringToWidth(fmt.Sprintf(fstring, s), maxTextWidth) - - return style.Render(fstring) + return fmt.Sprintf(fstring, s) } func truncateStringToWidth(s string, width int) string { @@ -82,39 +73,177 @@ func truncateStringToWidth(s string, width int) string { return s } -func renderHelpArea(s string) string { - var style = lipgloss.NewStyle(). - Width(initialViewPortWidth). - Height(1). - Align(lipgloss.Left). - Padding(0, 1). - MarginTop(1). - Foreground(lipgloss.Color("240")) +func (m Model) template() string { + template, err := template.New("incident").Funcs(funcMap).Parse(incidentTemplate) + if err != nil { + log.Fatal(err) + } + + o := new(bytes.Buffer) + summary := summarize(m.selectedIncident, m.selectedIncidentAlerts, m.selectedIncidentNotes) + err = template.Execute(o, summary) + if err != nil { + log.Fatal(err) + } + + return o.String() +} + +func summarize(i *pagerduty.Incident, a []pagerduty.IncidentAlert, n []pagerduty.IncidentNote) incidentSummary { + summary := summarizeIncident(i) + summary.Alerts = summarizeAlerts(a) + summary.Notes = summarizeNotes(n) + return summary +} + +type noteSummary struct { + ID string + User string + Content string + Created string +} + +func summarizeNotes(n []pagerduty.IncidentNote) []noteSummary { + var s []noteSummary + + for _, note := range n { + s = append(s, noteSummary{ + ID: note.ID, + User: note.User.Summary, + Content: note.Content, + Created: note.CreatedAt, + }) + } + + return s +} + +type alertSummary struct { + ID string + Self string + Service string + Created string + Status string + Incident string + Details map[string]interface{} +} + +func summarizeAlerts(a []pagerduty.IncidentAlert) []alertSummary { + var s []alertSummary + + for _, alt := range a { + s = append(s, alertSummary{ + ID: alt.ID, + Self: alt.Self, + Service: alt.Service.Summary, + Created: alt.CreatedAt, + Status: alt.Status, + Incident: alt.Incident.ID, + Details: alt.Body["details"].(map[string]interface{}), + }) + } + + return s +} + +type incidentSummary struct { + ID string + Title string + Self string + Service string + EscalationPolicy string + Created string + Urgency string + Priority string + Status string + Teams []string + Assigned []string + Acknowledged []string + Alerts []alertSummary + Notes []noteSummary +} + +func summarizeIncident(i *pagerduty.Incident) incidentSummary { + var s incidentSummary - return style.Render(s) + s.ID = i.ID + s.Title = i.Title + s.Self = i.Self + s.Service = i.Service.Summary + s.EscalationPolicy = i.EscalationPolicy.Summary + s.Created = i.CreatedAt + s.Urgency = i.Urgency + s.Status = i.Status + + if i.Priority != nil { + s.Priority = i.Priority.Summary + } + + for _, team := range i.Teams { + s.Teams = append(s.Teams, team.Summary) + } + for _, asn := range i.Assignments { + s.Assigned = append(s.Assigned, asn.Assignee.Summary) + } + for _, ack := range i.Acknowledgements { + s.Acknowledged = append(s.Acknowledged, ack.Acknowledger.Summary) + } + + return s } -// Gotta figure out how to accurately update the width on screen resize -// var logArea = lipgloss.NewStyle(). -// Width(initialTableWidth). -// Height(1). -// Align(lipgloss.Left). -// BorderStyle(lipgloss.NormalBorder()). -// BorderForeground(lipgloss.Color("240")). -// Bold(false) -// -// var incidentScreenArea = lipgloss.NewStyle(). -// Width(initialTableWidth). -// Height(initialTableHeight+2). -// Align(lipgloss.Center, lipgloss.Center). -// BorderStyle(lipgloss.NormalBorder()). -// BorderForeground(lipgloss.Color("240")). -// Bold(false) -// -// var logScreenArea = lipgloss.NewStyle(). -// Width(initialTableWidth). -// Height(initialTableHeight). -// Align(lipgloss.Left). -// BorderStyle(lipgloss.NormalBorder()). -// BorderForeground(lipgloss.Color("240")). -// Bold(false) +var funcMap = template.FuncMap{ + "Truncate": func(s string) string { + return fmt.Sprintf("%s ...", s[:5]) + }, + "ToUpper": strings.ToUpper, +} + +const incidentTemplate = ` +# {{ .ID }} + +{{ if .Priority }}PRIORITY {{ .Priority }} - {{ end }}{{ .Title }} + +{{ .Self }} + +## Summary + +* Service: {{ .Service }} +* Status: {{ .Status }} +* Priority: {{ .Priority }} +* Urgency: {{ .Urgency }} +* Created: {{ .Created }} + +## Responders and Escalation + +{{ if not .Acknowledged -}} +Assigned to:{{ range $assignee := .Assigned }} ++ {{ $assignee }} +{{ end -}} +{{ else -}} +Acknowledged by:{{ range $ack := .Acknowledged }} +* {{ $ack }} +{{ end -}} +{{ end -}} + +## Notes + +{{ range $note := .Notes }} +> {{ $note.Content }} + +- {{ $note.User }} @ {{ $note.Created }} +{{ end }} + +## Alerts ({{ len .Alerts }}) + +{{ range $alert := .Alerts }} +### {{ $alert.ID }} +* Service: {{ $alert.Service }} +* Status: {{ $alert.Status }} +* Created: {{ $alert.Created }} +* Link: {{ $alert.Self }} +{{ $alert.Details }} +{{ end }} + + +`