From 061b7dc671b17f8f6740bc1a87d033ec2b2cab9b Mon Sep 17 00:00:00 2001 From: Marcus Carr Date: Fri, 28 Aug 2026 01:15:33 -0400 Subject: [PATCH] Add PagerDuty incident TUI --- .pagerduty.yml.example | 1 + README.md | 33 ++ cmd/root.go | 44 +-- cmd/tui/api.go | 294 ++++++++++++++++++ cmd/tui/api_test.go | 96 ++++++ cmd/tui/tui.go | 690 +++++++++++++++++++++++++++++++++++++++++ go.mod | 13 +- go.sum | 74 ++++- 8 files changed, 1212 insertions(+), 33 deletions(-) create mode 100644 cmd/tui/api.go create mode 100644 cmd/tui/api_test.go create mode 100644 cmd/tui/tui.go diff --git a/.pagerduty.yml.example b/.pagerduty.yml.example index 8a74ffd..3e41a9c 100644 --- a/.pagerduty.yml.example +++ b/.pagerduty.yml.example @@ -1,2 +1,3 @@ --- api-token: 'test-api-token' +user-email: 'you@example.com' diff --git a/README.md b/README.md index fdc4714..a50fe9f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ have the following information: ```yaml --- api-token: +user-email: ``` 3. Download the [latest binary release](https://github.com/jdlubrano/pagerduty-cli/releases) @@ -27,6 +28,11 @@ and place the binary somewhere in your `$PATH`. verbose. You can add `alias pd="pagerduty-cli"` to your shell's profile if you so choose. +The `user-email` value is required for incident write operations in the TUI +because PagerDuty uses it as the `From` header. Protect the configuration file +with `chmod 600 ~/.pagerduty.yml`. You can alternatively set +`PAGERDUTY_API_TOKEN` and `PAGERDUTY_USER_EMAIL` in the environment. + ### Upgrading Upgrading is still a work in progress, so in the meantime, repeat step 3 from @@ -41,6 +47,33 @@ strives to be self-service and self-documenting. You can run commands. You can see additional help for subcommands by running `pagerduty-cli help `. +### Incident console + +Run `pagerduty-cli tui` to open a full-screen terminal console for the newest +100 incidents. The console supports filtering and refreshing incidents, +viewing details and notes, acknowledging and resolving, adding notes and status +updates, reassigning, escalating, snoozing, changing urgency, and opening an +incident in the PagerDuty web UI. + +The primary keyboard shortcuts are: + +| Key | Action | +|---|---| +| `j` / `k`, arrows | Move through incidents or scroll the focused view | +| `Enter` / `Esc` | Focus incident details / return to the incident list | +| `r` | Refresh | +| `1` | Open incidents (triggered and acknowledged) | +| `2` / `3` / `4` / `5` | Triggered / acknowledged / resolved / all incidents | +| `a` / `x` | Acknowledge / resolve | +| `n` / `p` | Add a note / send a status update | +| `g` / `e` | Reassign / escalate | +| `s` / `u` | Snooze / toggle urgency | +| `o` | Open in the PagerDuty web UI | +| `?` / `q` | Show help / quit | + +Without `user-email` (or `PAGERDUTY_USER_EMAIL`), the console remains available +for viewing incidents but disables write actions. + ## Development 1. Checkout this repository. diff --git a/cmd/root.go b/cmd/root.go index 2ac6fd7..104778a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,35 +1,37 @@ package cmd import ( - "os" - "fmt" + "fmt" + "os" - "github.com/spf13/cobra" + "github.com/spf13/cobra" - "github.com/jdlubrano/pagerduty-cli/cmd/on_call" - "github.com/jdlubrano/pagerduty-cli/cmd/schedules" - "github.com/jdlubrano/pagerduty-cli/cmd/users" - "github.com/jdlubrano/pagerduty-cli/version" + "github.com/jdlubrano/pagerduty-cli/cmd/on_call" + "github.com/jdlubrano/pagerduty-cli/cmd/schedules" + "github.com/jdlubrano/pagerduty-cli/cmd/tui" + "github.com/jdlubrano/pagerduty-cli/cmd/users" + "github.com/jdlubrano/pagerduty-cli/version" ) func NewRootCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "pagerduty-cli", - Short: "A CLI to interact with Pagerduty", - Long: "A CLI to interact with the Pagerduty API (https://v2.developer.pagerduty.com)", - Version: version.Version, - } + cmd := &cobra.Command{ + Use: "pagerduty-cli", + Short: "A CLI to interact with Pagerduty", + Long: "A CLI to interact with the Pagerduty API (https://v2.developer.pagerduty.com)", + Version: version.Version, + } - cmd.AddCommand(on_call.NewOnCallCmd()) - cmd.AddCommand(schedules.NewSchedulesCmd()) - cmd.AddCommand(users.NewUsersCmd()) + cmd.AddCommand(on_call.NewOnCallCmd()) + cmd.AddCommand(schedules.NewSchedulesCmd()) + cmd.AddCommand(tui.NewTUICmd()) + cmd.AddCommand(users.NewUsersCmd()) - return cmd + return cmd } func Execute() { - if err := NewRootCmd().Execute(); err != nil { - fmt.Println(err) - os.Exit(1) - } + if err := NewRootCmd().Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } } diff --git a/cmd/tui/api.go b/cmd/tui/api.go new file mode 100644 index 0000000..d3c573b --- /dev/null +++ b/cmd/tui/api.go @@ -0,0 +1,294 @@ +package tui + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const pagerDutyAPIBaseURL = "https://api.pagerduty.com" + +type Reference struct { + ID string `json:"id"` + Type string `json:"type"` + Summary string `json:"summary"` + HTMLURL string `json:"html_url"` +} + +type Assignment struct { + At string `json:"at"` + Assignee Reference `json:"assignee"` +} + +type Priority struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type IncidentBody struct { + Type string `json:"type"` + Details string `json:"details"` +} + +type Incident struct { + ID string `json:"id"` + IncidentNumber uint `json:"incident_number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Urgency string `json:"urgency"` + CreatedAt string `json:"created_at"` + LastStatusChangeAt string `json:"last_status_change_at"` + HTMLURL string `json:"html_url"` + Service Reference `json:"service"` + EscalationPolicy Reference `json:"escalation_policy"` + Assignments []Assignment `json:"assignments"` + Priority *Priority `json:"priority"` + Body IncidentBody `json:"body"` +} + +type Note struct { + ID string `json:"id"` + User Reference `json:"user"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` +} + +type User struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Summary string `json:"summary"` + Email string `json:"email"` + HTMLURL string `json:"html_url"` +} + +type incidentListResponse struct { + Incidents []Incident `json:"incidents"` + Limit int `json:"limit"` + Offset int `json:"offset"` + More bool `json:"more"` + Total int `json:"total"` +} + +type pdClient struct { + token string + baseURL string + http *http.Client +} + +func newPDClient(token string) *pdClient { + return &pdClient{ + token: token, + baseURL: pagerDutyAPIBaseURL, + http: &http.Client{Timeout: 20 * time.Second}, + } +} + +func (c *pdClient) ListIncidents(statuses []string, limit, offset int) (*incidentListResponse, error) { + if limit <= 0 || limit > 100 { + limit = 100 + } + query := url.Values{} + query.Set("limit", strconv.Itoa(limit)) + query.Set("offset", strconv.Itoa(offset)) + query.Set("sort_by", "created_at:desc") + for _, status := range statuses { + if status != "" { + query.Add("statuses[]", status) + } + } + + var result incidentListResponse + if err := c.request(http.MethodGet, "/incidents?"+query.Encode(), nil, "", &result); err != nil { + return nil, err + } + return &result, nil +} + +func (c *pdClient) ListNotes(id string) ([]Note, error) { + var result struct { + Notes []Note `json:"notes"` + } + if err := c.request(http.MethodGet, "/incidents/"+url.PathEscape(id)+"/notes", nil, "", &result); err != nil { + return nil, err + } + return result.Notes, nil +} + +func (c *pdClient) AddNote(id, from, content string) (*Note, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, fmt.Errorf("note cannot be empty") + } + payload := map[string]interface{}{ + "note": map[string]string{"content": content}, + } + var result struct { + Note Note `json:"note"` + } + if err := c.request(http.MethodPost, "/incidents/"+url.PathEscape(id)+"/notes", payload, from, &result); err != nil { + return nil, err + } + return &result.Note, nil +} + +func (c *pdClient) AddStatusUpdate(id, from, message string) error { + message = strings.TrimSpace(message) + if message == "" { + return fmt.Errorf("status update cannot be empty") + } + return c.request( + http.MethodPost, + "/incidents/"+url.PathEscape(id)+"/status_updates", + map[string]string{"message": message}, + from, + nil, + ) +} + +func (c *pdClient) Acknowledge(id, from string) error { + return c.manage(id, from, map[string]interface{}{"status": "acknowledged"}) +} + +func (c *pdClient) Resolve(id, from string) error { + return c.manage(id, from, map[string]interface{}{"status": "resolved"}) +} + +func (c *pdClient) SetUrgency(id, from, urgency string) error { + if urgency != "high" && urgency != "low" { + return fmt.Errorf("urgency must be high or low") + } + return c.manage(id, from, map[string]interface{}{"urgency": urgency}) +} + +func (c *pdClient) Escalate(id, from string, level int) error { + if level < 1 { + return fmt.Errorf("escalation level must be at least 1") + } + return c.manage(id, from, map[string]interface{}{"escalation_level": level}) +} + +func (c *pdClient) Assign(id, from, userID string) error { + if strings.TrimSpace(userID) == "" { + return fmt.Errorf("user ID cannot be empty") + } + return c.manage(id, from, map[string]interface{}{ + "assignments": []map[string]interface{}{ + {"assignee": map[string]string{"id": userID, "type": "user_reference"}}, + }, + }) +} + +func (c *pdClient) Snooze(id, from string, durationSeconds int) error { + if durationSeconds <= 0 { + return fmt.Errorf("snooze duration must be greater than zero") + } + return c.request( + http.MethodPost, + "/incidents/"+url.PathEscape(id)+"/snooze", + map[string]int{"duration": durationSeconds}, + from, + nil, + ) +} + +func (c *pdClient) ListUsers(queryText string) ([]User, error) { + query := url.Values{} + query.Set("limit", "100") + if strings.TrimSpace(queryText) != "" { + query.Set("query", strings.TrimSpace(queryText)) + } + var result struct { + Users []User `json:"users"` + } + if err := c.request(http.MethodGet, "/users?"+query.Encode(), nil, "", &result); err != nil { + return nil, err + } + return result.Users, nil +} + +func (c *pdClient) manage(id, from string, changes map[string]interface{}) error { + incident := map[string]interface{}{ + "id": id, + "type": "incident", + } + for key, value := range changes { + incident[key] = value + } + payload := map[string]interface{}{ + "incidents": []interface{}{incident}, + } + return c.request(http.MethodPut, "/incidents", payload, from, nil) +} + +func (c *pdClient) request(method, path string, payload interface{}, from string, target interface{}) error { + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, c.baseURL+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Token token="+c.token) + req.Header.Set("Accept", "application/vnd.pagerduty+json;version=2") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "pagerduty-cli-tui") + if strings.TrimSpace(from) != "" { + req.Header.Set("From", strings.TrimSpace(from)) + } + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return parsePagerDutyError(resp.StatusCode, responseBody) + } + if target == nil || len(responseBody) == 0 { + return nil + } + if err := json.Unmarshal(responseBody, target); err != nil { + return fmt.Errorf("decode PagerDuty response: %w", err) + } + return nil +} + +func parsePagerDutyError(status int, body []byte) error { + var payload struct { + Error struct { + Message string `json:"message"` + Errors []string `json:"errors"` + } `json:"error"` + } + if err := json.Unmarshal(body, &payload); err == nil && payload.Error.Message != "" { + if len(payload.Error.Errors) > 0 { + return fmt.Errorf("PagerDuty API %d: %s (%s)", status, payload.Error.Message, strings.Join(payload.Error.Errors, "; ")) + } + return fmt.Errorf("PagerDuty API %d: %s", status, payload.Error.Message) + } + text := strings.TrimSpace(string(body)) + if text == "" { + text = http.StatusText(status) + } + return fmt.Errorf("PagerDuty API %d: %s", status, text) +} diff --git a/cmd/tui/api_test.go b/cmd/tui/api_test.go new file mode 100644 index 0000000..724df49 --- /dev/null +++ b/cmd/tui/api_test.go @@ -0,0 +1,96 @@ +package tui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func testClient(server *httptest.Server) *pdClient { + client := newPDClient("test-token") + client.baseURL = server.URL + client.http = server.Client() + return client +} + +func TestListIncidentsIncludesRepeatedStatuses(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Token token=test-token" { + t.Fatalf("unexpected Authorization header: %q", got) + } + statuses := r.URL.Query()["statuses[]"] + if len(statuses) != 2 || statuses[0] != "triggered" || statuses[1] != "acknowledged" { + t.Fatalf("unexpected statuses query: %#v", statuses) + } + if got := r.URL.Query().Get("sort_by"); got != "created_at:desc" { + t.Fatalf("unexpected sort_by: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"incidents":[],"limit":100,"offset":0,"more":false}`)) + })) + defer server.Close() + + client := testClient(server) + if _, err := client.ListIncidents([]string{"triggered", "acknowledged"}, 100, 0); err != nil { + t.Fatal(err) + } +} + +func TestAddNoteUsesFromHeader(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + if got := r.Header.Get("From"); got != "operator@example.com" { + t.Fatalf("unexpected From header: %q", got) + } + var body map[string]map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if got := body["note"]["content"]; got != "investigating database latency" { + t.Fatalf("unexpected note content: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"note":{"id":"N1","content":"investigating database latency"}}`)) + })) + defer server.Close() + + client := testClient(server) + if _, err := client.AddNote("P123", "operator@example.com", "investigating database latency"); err != nil { + t.Fatal(err) + } +} + +func TestAcknowledgeUsesManageIncidentsPayload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/incidents" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("From"); got != "operator@example.com" { + t.Fatalf("unexpected From header: %q", got) + } + var body struct { + Incidents []struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + } `json:"incidents"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Incidents) != 1 || body.Incidents[0].ID != "P123" || body.Incidents[0].Type != "incident" || body.Incidents[0].Status != "acknowledged" { + t.Fatalf("unexpected payload: %#v", body.Incidents) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"incidents":[]}`)) + })) + defer server.Close() + + client := testClient(server) + if err := client.Acknowledge("P123", "operator@example.com"); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go new file mode 100644 index 0000000..887455e --- /dev/null +++ b/cmd/tui/tui.go @@ -0,0 +1,690 @@ +package tui + +import ( + "fmt" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/jdlubrano/pagerduty-cli/config" + "github.com/rivo/tview" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type incidentFilter struct { + Name string + Statuses []string +} + +type model struct { + app *tview.Application + pages *tview.Pages + table *tview.Table + details *tview.TextView + notes *tview.TextView + status *tview.TextView + header *tview.TextView + client *pdClient + fromEmail string + + incidents []Incident + filter incidentFilter + modalOpen bool +} + +var filters = map[rune]incidentFilter{ + '1': {Name: "open", Statuses: []string{"triggered", "acknowledged"}}, + '2': {Name: "triggered", Statuses: []string{"triggered"}}, + '3': {Name: "acknowledged", Statuses: []string{"acknowledged"}}, + '4': {Name: "resolved", Statuses: []string{"resolved"}}, + '5': {Name: "all", Statuses: nil}, +} + +func NewTUICmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tui", + Short: "Open an interactive PagerDuty incident console", + Long: "Open a Linux terminal UI for viewing and managing PagerDuty incidents, notes, assignments, escalation, urgency, and snoozes.", + RunE: func(cmd *cobra.Command, args []string) error { + if configuredAPIToken() == "" { + return fmt.Errorf("PagerDuty API token is missing; set api-token in ~/.pagerduty.yml or PAGERDUTY_API_TOKEN") + } + return run() + }, + } + return cmd +} + +func run() error { + m := &model{ + app: tview.NewApplication(), + client: newPDClient(configuredAPIToken()), + fromEmail: configuredUserEmail(), + filter: filters['1'], + } + m.buildUI() + m.refreshIncidents() + return m.app.SetRoot(m.pages, true).EnableMouse(true).Run() +} + +func (m *model) buildUI() { + m.header = tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignLeft) + m.header.SetBorderPadding(0, 0, 1, 1) + + m.table = tview.NewTable(). + SetSelectable(true, false). + SetFixed(1, 0). + SetBorders(false) + m.table.SetBorder(true).SetTitle(" Incidents ") + m.table.SetSelectionChangedFunc(func(row, column int) { + if row <= 0 || row-1 >= len(m.incidents) { + return + } + m.renderDetails(m.incidents[row-1]) + m.loadNotes(m.incidents[row-1].ID) + }) + m.table.SetSelectedFunc(func(row, column int) { + if row > 0 { + m.app.SetFocus(m.details) + } + }) + + m.details = tview.NewTextView(). + SetDynamicColors(true). + SetWrap(true). + SetWordWrap(true). + SetScrollable(true) + m.details.SetBorder(true).SetTitle(" Incident details ") + + m.notes = tview.NewTextView(). + SetDynamicColors(true). + SetWrap(true). + SetWordWrap(true). + SetScrollable(true) + m.notes.SetBorder(true).SetTitle(" Notes / comments ") + + m.status = tview.NewTextView().SetDynamicColors(true) + m.status.SetBorderPadding(0, 0, 1, 1) + + right := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(m.details, 0, 3, false). + AddItem(m.notes, 0, 2, false) + + body := tview.NewFlex(). + AddItem(m.table, 0, 3, true). + AddItem(right, 0, 2, false) + + root := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(m.header, 2, 0, false). + AddItem(body, 0, 1, true). + AddItem(m.status, 2, 0, false) + + m.pages = tview.NewPages().AddPage("main", root, true, true) + m.renderHeader() + m.setStatus("r refresh 1 open 2 triggered 3 ack 4 resolved 5 all Enter details ? help q quit") + + m.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if m.modalOpen { + return event + } + + if event.Key() == tcell.KeyEsc { + m.app.SetFocus(m.table) + return nil + } + if event.Key() != tcell.KeyRune { + return event + } + + switch event.Rune() { + case 'q': + m.app.Stop() + return nil + case 'r': + m.refreshIncidents() + return nil + case '?': + m.showHelp() + return nil + case 'a': + m.acknowledgeSelected() + return nil + case 'x': + m.resolveSelected() + return nil + case 'n': + m.noteSelected() + return nil + case 'p': + m.statusUpdateSelected() + return nil + case 'g': + m.assignSelected() + return nil + case 'e': + m.escalateSelected() + return nil + case 's': + m.snoozeSelected() + return nil + case 'u': + m.toggleUrgencySelected() + return nil + case 'o': + m.openSelected() + return nil + } + + if filter, ok := filters[event.Rune()]; ok { + m.filter = filter + m.renderHeader() + m.refreshIncidents() + return nil + } + return event + }) +} + +func (m *model) renderHeader() { + writeMode := "[green]write enabled[-]" + if strings.TrimSpace(m.fromEmail) == "" { + writeMode = "[yellow]read-only until user-email is configured[-]" + } + m.header.SetText(fmt.Sprintf("[::b]PagerDuty Incident Console[::-] filter: [aqua]%s[-] %s", tview.Escape(m.filter.Name), writeMode)) +} + +func (m *model) refreshIncidents() { + m.setStatus("[aqua]Refreshing incidents...[-]") + filter := m.filter + go func() { + result, err := m.client.ListIncidents(filter.Statuses, 100, 0) + m.app.QueueUpdateDraw(func() { + if err != nil { + m.setStatus("[red]Refresh failed: " + tview.Escape(err.Error()) + "[-]") + return + } + m.incidents = result.Incidents + m.renderIncidentTable() + more := "" + if result.More { + more = " (showing first 100)" + } + m.setStatus(fmt.Sprintf("[green]Loaded %d incident(s)%s.[-] r refresh ? help q quit", len(result.Incidents), more)) + }) + }() +} + +func (m *model) renderIncidentTable() { + m.table.Clear() + headers := []string{"#", "Status", "Urgency", "Service", "Title", "Age"} + for column, header := range headers { + m.table.SetCell(0, column, tview.NewTableCell("[::b]"+header+"[::-]"). + SetSelectable(false). + SetExpansion(boolToInt(column == 4))) + } + + for row, incident := range m.incidents { + statusText, statusColor := styleStatus(incident.Status) + urgency := incident.Urgency + if urgency == "high" { + urgency = "[red]high[-]" + } + m.table.SetCell(row+1, 0, tview.NewTableCell(fmt.Sprintf("%d", incident.IncidentNumber)).SetTextColor(tcell.ColorGray)) + m.table.SetCell(row+1, 1, tview.NewTableCell(statusText).SetTextColor(statusColor)) + m.table.SetCell(row+1, 2, tview.NewTableCell(urgency)) + m.table.SetCell(row+1, 3, tview.NewTableCell(incident.Service.Summary)) + m.table.SetCell(row+1, 4, tview.NewTableCell(incident.Title).SetExpansion(1)) + m.table.SetCell(row+1, 5, tview.NewTableCell(relativeAge(incident.CreatedAt)).SetTextColor(tcell.ColorGray)) + } + + if len(m.incidents) == 0 { + m.details.SetText("No incidents match the current filter.") + m.notes.SetText("") + return + } + m.table.Select(1, 0) + m.renderDetails(m.incidents[0]) + m.loadNotes(m.incidents[0].ID) +} + +func (m *model) renderDetails(incident Incident) { + assignees := make([]string, 0, len(incident.Assignments)) + for _, assignment := range incident.Assignments { + if assignment.Assignee.Summary != "" { + assignees = append(assignees, assignment.Assignee.Summary) + } + } + if len(assignees) == 0 { + assignees = append(assignees, "unassigned") + } + + priority := "-" + if incident.Priority != nil && incident.Priority.Name != "" { + priority = incident.Priority.Name + } + + details := incident.Body.Details + if details == "" { + details = incident.Description + } + if details == "" { + details = "-" + } + + status, _ := styleStatus(incident.Status) + text := fmt.Sprintf( + "[::b]#%d %s[::-]\n\nStatus: %s\nUrgency: %s\nPriority: %s\nService: %s\nAssigned: %s\nEscalation policy: %s\nCreated: %s\nLast change: %s\n\n[::b]Details[::-]\n%s\n\n[gray]ID: %s[-]", + incident.IncidentNumber, + tview.Escape(incident.Title), + status, + tview.Escape(incident.Urgency), + tview.Escape(priority), + tview.Escape(incident.Service.Summary), + tview.Escape(strings.Join(assignees, ", ")), + tview.Escape(incident.EscalationPolicy.Summary), + tview.Escape(formatTime(incident.CreatedAt)), + tview.Escape(formatTime(incident.LastStatusChangeAt)), + tview.Escape(details), + tview.Escape(incident.ID), + ) + m.details.SetText(text) +} + +func (m *model) loadNotes(incidentID string) { + if incidentID == "" { + return + } + m.notes.SetText("[gray]Loading notes...[-]") + go func(id string) { + notes, err := m.client.ListNotes(id) + m.app.QueueUpdateDraw(func() { + current, ok := m.selectedIncident() + if !ok || current.ID != id { + return + } + if err != nil { + m.notes.SetText("[red]Could not load notes: " + tview.Escape(err.Error()) + "[-]") + return + } + m.renderNotes(notes) + }) + }(incidentID) +} + +func (m *model) renderNotes(notes []Note) { + if len(notes) == 0 { + m.notes.SetText("[gray]No notes yet. Press n to add one.[-]") + return + } + sort.SliceStable(notes, func(i, j int) bool { return notes[i].CreatedAt > notes[j].CreatedAt }) + var b strings.Builder + for _, note := range notes { + author := note.User.Summary + if author == "" { + author = "PagerDuty user" + } + fmt.Fprintf(&b, "[aqua]%s[-] [gray]%s[-]\n%s\n\n", tview.Escape(author), tview.Escape(formatTime(note.CreatedAt)), tview.Escape(note.Content)) + } + m.notes.SetText(strings.TrimSpace(b.String())) +} + +func (m *model) acknowledgeSelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + if incident.Status == "acknowledged" { + m.setStatus("[yellow]Incident is already acknowledged.[-]") + return + } + m.runWriteAction("Acknowledging incident", func() error { + return m.client.Acknowledge(incident.ID, m.fromEmail) + }) +} + +func (m *model) resolveSelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + m.confirm("Resolve incident", fmt.Sprintf("Resolve #%d?", incident.IncidentNumber), func() { + m.runWriteAction("Resolving incident", func() error { + return m.client.Resolve(incident.ID, m.fromEmail) + }) + }) +} + +func (m *model) noteSelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + m.prompt("Add note / comment", "Note", "", func(value string) { + m.runWriteAction("Adding note", func() error { + _, err := m.client.AddNote(incident.ID, m.fromEmail, value) + return err + }) + }) +} + +func (m *model) statusUpdateSelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + m.prompt("Send status update", "Message", "", func(value string) { + m.runWriteAction("Sending status update", func() error { + return m.client.AddStatusUpdate(incident.ID, m.fromEmail, value) + }) + }) +} + +func (m *model) assignSelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + m.prompt("Find PagerDuty user", "Name/email", "", func(query string) { + m.setStatus("[aqua]Searching users...[-]") + go func() { + users, err := m.client.ListUsers(query) + m.app.QueueUpdateDraw(func() { + if err != nil { + m.setStatus("[red]User search failed: " + tview.Escape(err.Error()) + "[-]") + return + } + m.showUserPicker(incident, users) + }) + }() + }) +} + +func (m *model) showUserPicker(incident Incident, users []User) { + if len(users) == 0 { + m.setStatus("[yellow]No users matched that search.[-]") + return + } + + list := tview.NewList().ShowSecondaryText(true) + list.SetBorder(true).SetTitle(" Assign incident ") + for _, user := range users { + user := user + name := user.Name + if name == "" { + name = user.Summary + } + list.AddItem(name, user.Email, 0, func() { + m.closeModal() + m.runWriteAction("Assigning incident", func() error { + return m.client.Assign(incident.ID, m.fromEmail, user.ID) + }) + }) + } + list.SetDoneFunc(func() { m.closeModal() }) + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + m.closeModal() + return nil + } + return event + }) + m.openModal("users", centered(list, 80, 22), list) +} + +func (m *model) escalateSelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + m.prompt("Escalate incident", "Escalation level", "2", func(value string) { + level, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || level < 1 { + m.setStatus("[red]Escalation level must be a positive number.[-]") + return + } + m.runWriteAction("Escalating incident", func() error { + return m.client.Escalate(incident.ID, m.fromEmail, level) + }) + }) +} + +func (m *model) snoozeSelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + m.prompt("Snooze incident", "Minutes", "30", func(value string) { + minutes, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || minutes <= 0 { + m.setStatus("[red]Snooze minutes must be a positive number.[-]") + return + } + m.runWriteAction("Snoozing incident", func() error { + return m.client.Snooze(incident.ID, m.fromEmail, minutes*60) + }) + }) +} + +func (m *model) toggleUrgencySelected() { + incident, ok := m.selectedIncident() + if !ok { + return + } + urgency := "high" + if incident.Urgency == "high" { + urgency = "low" + } + m.runWriteAction("Changing urgency to "+urgency, func() error { + return m.client.SetUrgency(incident.ID, m.fromEmail, urgency) + }) +} + +func (m *model) openSelected() { + incident, ok := m.selectedIncident() + if !ok || incident.HTMLURL == "" { + m.setStatus("[yellow]No PagerDuty web URL is available for this incident.[-]") + return + } + if err := exec.Command("xdg-open", incident.HTMLURL).Start(); err != nil { + m.setStatus("[red]Could not open browser: " + tview.Escape(err.Error()) + "[-]") + return + } + m.setStatus("[green]Opened incident in your browser.[-]") +} + +func (m *model) runWriteAction(label string, action func() error) { + if strings.TrimSpace(m.fromEmail) == "" { + m.showMessage("Write action unavailable", "Set user-email in ~/.pagerduty.yml or export PAGERDUTY_USER_EMAIL. PagerDuty requires the acting user's email for incident write actions.") + return + } + m.setStatus("[aqua]" + tview.Escape(label) + "...[-]") + go func() { + err := action() + m.app.QueueUpdateDraw(func() { + if err != nil { + m.setStatus("[red]" + tview.Escape(label) + " failed: " + tview.Escape(err.Error()) + "[-]") + return + } + m.setStatus("[green]" + tview.Escape(label) + " succeeded.[-]") + m.refreshIncidents() + }) + }() +} + +func (m *model) selectedIncident() (Incident, bool) { + row, _ := m.table.GetSelection() + if row <= 0 || row-1 >= len(m.incidents) { + return Incident{}, false + } + return m.incidents[row-1], true +} + +func (m *model) showHelp() { + help := `Keyboard shortcuts + +Navigation + ↑/↓ or j/k Move through incidents + Enter Focus incident details + Esc Return to incident list + r Refresh + 1 Open (triggered + acknowledged) + 2 Triggered only + 3 Acknowledged only + 4 Resolved only + 5 All statuses (first 100) + +Incident actions + a Acknowledge + x Resolve (asks for confirmation) + n Add note / comment + p Send status update + g Reassign to a PagerDuty user + e Escalate to a selected level + s Snooze for N minutes + u Toggle urgency high/low + o Open incident in PagerDuty web UI + +General + ? This help + q Quit + +Write actions require user-email in ~/.pagerduty.yml or PAGERDUTY_USER_EMAIL.` + modal := tview.NewModal().SetText(help).AddButtons([]string{"Close"}) + modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { m.closeModal() }) + m.openModal("help", centered(modal, 72, 32), modal) +} + +func (m *model) showMessage(title, message string) { + modal := tview.NewModal().SetText(message).AddButtons([]string{"OK"}) + modal.SetTitle(" " + title + " ").SetBorder(true) + modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { m.closeModal() }) + m.openModal("message", centered(modal, 72, 12), modal) +} + +func (m *model) confirm(title, message string, yes func()) { + modal := tview.NewModal().SetText(message).AddButtons([]string{"Cancel", "Yes"}) + modal.SetTitle(" " + title + " ").SetBorder(true) + modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + m.closeModal() + if buttonLabel == "Yes" { + yes() + } + }) + m.openModal("confirm", centered(modal, 60, 9), modal) +} + +func (m *model) prompt(title, label, initial string, done func(string)) { + input := tview.NewInputField().SetLabel(label + ": ").SetText(initial).SetFieldWidth(50) + form := tview.NewForm(). + AddFormItem(input). + AddButton("Submit", func() { + value := input.GetText() + m.closeModal() + done(value) + }). + AddButton("Cancel", func() { m.closeModal() }) + form.SetBorder(true).SetTitle(" " + title + " ") + form.SetCancelFunc(func() { m.closeModal() }) + m.openModal("prompt", centered(form, 72, 11), form) +} + +func (m *model) openModal(name string, primitive tview.Primitive, focus tview.Primitive) { + m.modalOpen = true + m.pages.AddPage("modal", primitive, true, true) + m.app.SetFocus(focus) +} + +func (m *model) closeModal() { + m.pages.RemovePage("modal") + m.modalOpen = false + m.app.SetFocus(m.table) +} + +func (m *model) setStatus(text string) { + m.status.SetText(text) +} + +func centered(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 0, true). + AddItem(nil, 0, 1, false), width, 0, true). + AddItem(nil, 0, 1, false) +} + +func styleStatus(status string) (string, tcell.Color) { + switch status { + case "triggered": + return "triggered", tcell.ColorRed + case "acknowledged": + return "acknowledged", tcell.ColorYellow + case "resolved": + return "resolved", tcell.ColorGreen + default: + return status, tcell.ColorWhite + } +} + +func formatTime(value string) string { + if value == "" { + return "-" + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return value + } + return t.Local().Format("2006-01-02 15:04:05 MST") +} + +func relativeAge(value string) string { + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return "-" + } + d := time.Since(t) + if d < 0 { + d = 0 + } + if d < time.Minute { + return "<1m" + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func configuredAPIToken() string { + if value := strings.TrimSpace(os.Getenv("PAGERDUTY_API_TOKEN")); value != "" { + return value + } + return strings.TrimSpace(config.GetApiToken()) +} + +func configuredUserEmail() string { + if value := strings.TrimSpace(os.Getenv("PAGERDUTY_USER_EMAIL")); value != "" { + return value + } + // If the API token came from ~/.pagerduty.yml, config.GetApiToken() has + // already loaded that file into Viper, so user-email is available here. + return strings.TrimSpace(viper.GetString("user-email")) +} diff --git a/go.mod b/go.mod index 4b7a5e7..308af51 100644 --- a/go.mod +++ b/go.mod @@ -3,29 +3,34 @@ module github.com/jdlubrano/pagerduty-cli go 1.19 require ( + github.com/gdamore/tcell/v2 v2.8.1 github.com/mitchellh/go-homedir v1.1.0 github.com/olekukonko/tablewriter v0.0.5 + github.com/rivo/tview v0.42.0 github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.14.0 ) require ( github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gdamore/encoding v1.0.1 // 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-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect - github.com/rivo/uniseg v0.4.3 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/afero v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect - golang.org/x/sys v0.4.0 // indirect - golang.org/x/text v0.6.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 3227fe5..db48476 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= +github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -98,7 +102,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -135,11 +140,13 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +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-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -155,9 +162,12 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -190,6 +200,7 @@ 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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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= @@ -202,7 +213,11 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -236,6 +251,11 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -267,6 +287,12 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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= @@ -286,6 +312,12 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -320,18 +352,40 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 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= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= -golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -382,6 +436,10 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=