Skip to content

Commit 9bdf567

Browse files
clcollinsclaude
andauthored
Add unit tests for views.go (#134)
* Fix nil pointer dereference panic when acknowledging incidents Resolves panic that occurred when trying to acknowledge incidents by removing unsafe dereferences of m.selectedIncident in message handler closures. The closures were capturing the incident pointer at creation time, which could be nil, rather than at execution time when the incident data is guaranteed to be available. Changes: - Modified acknowledge/unacknowledge message handlers to create empty message structs instead of dereferencing m.selectedIncident in closures - Updated Update() function handlers to safely retrieve incident from model state when processing messages - Added nil check in silence incidents handler to prevent panic when appending selected incident The fix ensures that incident data is accessed only after it has been fetched from PagerDuty and stored in the model, preventing nil pointer dereferences. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix performance issues and re-escalation bug Multiple performance and functionality improvements: - Fix re-escalation "Assignee cannot be empty" error by skipping unnecessary un-acknowledge step and going directly to re-escalation with proper user email authentication - Add comprehensive terminal escape sequence filtering to prevent fake keypresses from clogging the message queue (OSC, CSI, CPR responses) - Implement priority handling for all user keypresses to ensure responsive UI even when async messages are queued - Optimize auto-acknowledge feature with early return when disabled and cached on-call check to reduce API calls from N to 1 per cycle - Remove triple template rendering - now renders once on explicit request - Fix viewport consuming ESC and navigation keys with handledKey flag - Add debug logging to track re-escalation flow - Clean up unused code: remove unAcknowledgedIncidentsMsg and reEscalate parameter from acknowledgeIncidents() Performance impact: - ESC/Enter keys now respond immediately instead of waiting for queued messages - Auto-acknowledge overhead reduced to near-zero when feature is disabled - Eliminated redundant template renders (3x → 1x per incident view) Fixes: https://github.com/clcollins/srepd/issues/XXX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add unit tests for views.go helper functions Adds comprehensive unit tests for pure display/formatting functions in pkg/tui/views.go that were previously untested: - assigneeArea(): Tests formatting of "You", "Team", and custom assignee displays - statusArea(): Tests formatting of status messages with various content - refreshArea(): Tests all combinations of auto-refresh and auto-acknowledge flags - summarizeNotes(): Tests conversion of PagerDuty notes to display format - summarizeIncident(): Tests incident conversion including priority, assignments, and duplicate acknowledgement deduplication - addNoteTemplate(): Tests note template generation with incident context All 6 functions now have test coverage with 16 test cases total, covering: - Edge cases (empty inputs, nil values) - Normal operation (single/multiple items) - Business logic (deduplication, conditional formatting) Test coverage: pkg/tui/views_test.go Tests pass: go test ./pkg/tui -v 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix golangci-lint errors and add lint to test target Fixes ineffectual assignment warnings from golangci-lint and ensures linting runs before tests to catch issues early. Changes: - pkg/tui/msgHandlers.go: - Remove ineffectual assignments to handledKey in switchIncidentFocusMode() - All removed assignments were in cases that return immediately, making the assignment unreachable and unnecessary - Affected cases: Refresh, Ack, UnAck, Silence, Note, Login, Open - handledKey is only checked at the end of the function to determine whether to pass the message to the viewport - Makefile: - Add lint as a dependency to the test target - Ensures golangci-lint runs before tests to catch linting issues early - Updated help text to "Run tests (after linting)" Testing: - All golangci-lint checks pass (0 issues) - All unit tests pass - `make test` now runs linting automatically before tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9aa0243 commit 9bdf567

7 files changed

Lines changed: 495 additions & 112 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ tidy: ## Tidy up go modules
4545
go mod tidy
4646

4747
.PHONY: test
48-
test: ## Run tests
48+
test: lint ## Run tests (after linting)
4949
@echo "Running tests..."
5050
go test ./... -v $(TESTOPTS)
5151

pkg/pd/pd.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
"github.com/PagerDuty/go-pagerduty"
9+
"github.com/charmbracelet/log"
910
)
1011

1112
const (
@@ -115,21 +116,26 @@ func NewListIncidentOptsFromDefaults() pagerduty.ListIncidentsOptions {
115116

116117
}
117118

118-
func AcknowledgeIncident(client PagerDutyClient, incidents []pagerduty.Incident, user *pagerduty.User) ([]pagerduty.Incident, error) {
119+
func AcknowledgeIncident(client PagerDutyClient, incidents []pagerduty.Incident, user *pagerduty.User, currentUser *pagerduty.User) ([]pagerduty.Incident, error) {
119120
var ctx = context.Background()
120121
var email string
121122

122123
opts := []pagerduty.ManageIncidentsOptions{}
123124

125+
// Use currentUser's email for API call authentication
126+
if currentUser != nil {
127+
email = currentUser.Email
128+
}
129+
124130
for _, incident := range incidents {
125131
if user == nil {
126-
email = ""
132+
// Un-acknowledge: set escalation level without assignment
127133
opts = append(opts, pagerduty.ManageIncidentsOptions{
128134
ID: incident.ID,
129135
EscalationLevel: 1,
130136
})
131137
} else {
132-
email = user.Email
138+
// Acknowledge: assign to specific user
133139
opts = append(opts, pagerduty.ManageIncidentsOptions{
134140
ID: incident.ID,
135141
Status: "acknowledged",
@@ -302,8 +308,10 @@ func GetUserOnCalls(client PagerDutyClient, id string, opts pagerduty.ListOnCall
302308

303309
func loopManageIncidents(client PagerDutyClient, ctx context.Context, email string, opts []pagerduty.ManageIncidentsOptions) (incidentList []pagerduty.Incident, err error) {
304310
for {
311+
log.Debug("pd.loopManageIncidents", "email", email, "opts_count", len(opts))
305312
response, err := client.ManageIncidentsWithContext(ctx, email, opts)
306313
if err != nil {
314+
log.Error("pd.loopManageIncidents", "error", err, "email", email)
307315
return incidentList, err
308316
}
309317

@@ -347,7 +355,7 @@ func ReassignIncidents(client PagerDutyClient, incidents []pagerduty.Incident, u
347355
}
348356

349357
// ReEscalateIncidents re-escalates a list of incidents to an escalation policy at a specific level
350-
func ReEscalateIncidents(client PagerDutyClient, incidents []pagerduty.Incident, policy *pagerduty.EscalationPolicy, level uint) ([]pagerduty.Incident, error) {
358+
func ReEscalateIncidents(client PagerDutyClient, incidents []pagerduty.Incident, user *pagerduty.User, policy *pagerduty.EscalationPolicy, level uint) ([]pagerduty.Incident, error) {
351359
var ctx = context.Background()
352360

353361
opts := []pagerduty.ManageIncidentsOptions{}
@@ -364,7 +372,7 @@ func ReEscalateIncidents(client PagerDutyClient, incidents []pagerduty.Incident,
364372
})
365373
}
366374

367-
return loopManageIncidents(client, ctx, "", opts)
375+
return loopManageIncidents(client, ctx, user.Email, opts)
368376
}
369377

370378
func PostNote(client PagerDutyClient, id string, user *pagerduty.User, content string) (*pagerduty.IncidentNote, error) {

pkg/tui/commands.go

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,13 @@ func ShouldBeAcknowledged(p *pd.Config, i pagerduty.Incident, id string, autoAck
251251
"userIsOnCall", userIsOnCall,
252252
"doIt", doIt,
253253
)
254-
return AssignedToUser(i, id) && !AcknowledgedByUser(i, id) && autoAcknowledge
254+
return doIt
255+
}
256+
257+
// ShouldBeAcknowledgedCached checks if an incident should be auto-acknowledged using a cached userIsOnCall result.
258+
// This avoids repeated calls to UserIsOnCall() when checking multiple incidents in the same update cycle.
259+
func ShouldBeAcknowledgedCached(i pagerduty.Incident, id string, userIsOnCall bool) bool {
260+
return AssignedToUser(i, id) && !AcknowledgedByUser(i, id) && userIsOnCall
255261
}
256262

257263
// AssignedToUser returns true if the incident is assigned to the given user
@@ -523,22 +529,9 @@ type acknowledgedIncidentsMsg struct {
523529
incidents []pagerduty.Incident
524530
err error
525531
}
526-
type unAcknowledgedIncidentsMsg struct {
527-
incidents []pagerduty.Incident
528-
err error
529-
}
530-
531-
func acknowledgeIncidents(p *pd.Config, incidents []pagerduty.Incident, reEscalate bool) tea.Cmd {
532+
func acknowledgeIncidents(p *pd.Config, incidents []pagerduty.Incident) tea.Cmd {
532533
return func() tea.Msg {
533-
var err error
534-
535-
if reEscalate {
536-
a, err := pd.AcknowledgeIncident(p.Client, incidents, &pagerduty.User{})
537-
return unAcknowledgedIncidentsMsg{a, err}
538-
}
539-
540-
a, err := pd.AcknowledgeIncident(p.Client, incidents, p.CurrentUser)
541-
534+
a, err := pd.AcknowledgeIncident(p.Client, incidents, p.CurrentUser, p.CurrentUser)
542535
return acknowledgedIncidentsMsg{a, err}
543536
}
544537
}
@@ -573,7 +566,7 @@ type reEscalatedIncidentsMsg []pagerduty.Incident
573566

574567
func reEscalateIncidents(p *pd.Config, i []pagerduty.Incident, e *pagerduty.EscalationPolicy, l uint) tea.Cmd {
575568
return func() tea.Msg {
576-
r, err := pd.ReEscalateIncidents(p.Client, i, e, l)
569+
r, err := pd.ReEscalateIncidents(p.Client, i, p.CurrentUser, e, l)
577570
if err != nil {
578571
return errMsg{err}
579572
}

pkg/tui/commands_test.go

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,41 +30,21 @@ func TestAcknowledgeIncident(t *testing.T) {
3030
}
3131

3232
tests := []struct {
33-
name string
34-
incidents []pagerduty.Incident
35-
reEscalate bool
36-
expected tea.Msg
33+
name string
34+
incidents []pagerduty.Incident
35+
expected tea.Msg
3736
}{
3837
{
39-
name: "return unAcknowledgedIncidentMsg with non-nil error if error occurs while re-escalating",
40-
incidents: errIncidents,
41-
reEscalate: true,
42-
expected: unAcknowledgedIncidentsMsg{
43-
incidents: []pagerduty.Incident(nil),
44-
err: pd.ErrMockError,
45-
},
46-
},
47-
{
48-
name: "return unAcknowledgedIncidentMsg with an incident list if no error occurs while re-escalating",
49-
incidents: incidents,
50-
reEscalate: true,
51-
expected: unAcknowledgedIncidentsMsg{
52-
incidents: incidents,
53-
},
54-
},
55-
{
56-
name: "return acknowledgedIncidentMsg with non-nil error if error occurs while acknowledging",
57-
incidents: errIncidents,
58-
reEscalate: false,
38+
name: "return acknowledgedIncidentMsg with non-nil error if error occurs while acknowledging",
39+
incidents: errIncidents,
5940
expected: acknowledgedIncidentsMsg{
6041
incidents: []pagerduty.Incident(nil),
6142
err: pd.ErrMockError,
6243
},
6344
},
6445
{
65-
name: "return acknowledgedIncidentMsg with an incident list if no error occurs while acknowledging",
66-
incidents: incidents,
67-
reEscalate: false,
46+
name: "return acknowledgedIncidentMsg with an incident list if no error occurs while acknowledging",
47+
incidents: incidents,
6848
expected: acknowledgedIncidentsMsg{
6949
incidents: incidents,
7050
},
@@ -73,7 +53,7 @@ func TestAcknowledgeIncident(t *testing.T) {
7353

7454
for _, test := range tests {
7555
t.Run(test.name, func(t *testing.T) {
76-
cmd := acknowledgeIncidents(mockConfig, test.incidents, test.reEscalate)
56+
cmd := acknowledgeIncidents(mockConfig, test.incidents)
7757
actual := cmd()
7858
assert.Equal(t, test.expected, actual)
7959
})

pkg/tui/msgHandlers.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ func switchTableFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
269269
return waitForSelectedIncidentThenDoMsg{
270270
msg: "acknowledge",
271271
action: func() tea.Msg {
272-
return acknowledgeIncidentsMsg{incidents: []pagerduty.Incident{*m.selectedIncident}}
272+
return acknowledgeIncidentsMsg{}
273273
},
274274
}
275275
},
@@ -282,7 +282,7 @@ func switchTableFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
282282
return waitForSelectedIncidentThenDoMsg{
283283
msg: "un-acknowledge",
284284
action: func() tea.Msg {
285-
return unAcknowledgeIncidentsMsg{incidents: []pagerduty.Incident{*m.selectedIncident}}
285+
return unAcknowledgeIncidentsMsg{}
286286
},
287287
}
288288
},
@@ -346,24 +346,31 @@ func switchIncidentFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
346346
var cmd tea.Cmd
347347
var cmds []tea.Cmd
348348

349+
// Track if we handled the key ourselves
350+
handledKey := false
351+
349352
switch msg := msg.(type) {
350353
case tea.KeyMsg:
351354
switch {
352355
case key.Matches(msg, defaultKeyMap.Help):
353356
m.toggleHelp()
357+
handledKey = true
354358

355359
// This un-sets the selected incident and returns to the table view
356360
case key.Matches(msg, defaultKeyMap.Back):
357361
m.clearSelectedIncident(msg.String() + " (back)")
362+
m.table.Focus() // Ensure table regains focus immediately
363+
// Return immediately - no need to process anything else or update viewport
364+
return m, nil
358365

359366
case key.Matches(msg, defaultKeyMap.Refresh):
360367
return m, func() tea.Msg { return getIncidentMsg(m.selectedIncident.ID) }
361368

362369
case key.Matches(msg, defaultKeyMap.Ack):
363-
return m, func() tea.Msg { return acknowledgeIncidentsMsg{incidents: []pagerduty.Incident{*m.selectedIncident}} }
370+
return m, func() tea.Msg { return acknowledgeIncidentsMsg{} }
364371

365372
case key.Matches(msg, defaultKeyMap.UnAck):
366-
return m, func() tea.Msg { return unAcknowledgeIncidentsMsg{incidents: []pagerduty.Incident{*m.selectedIncident}} }
373+
return m, func() tea.Msg { return unAcknowledgeIncidentsMsg{} }
367374

368375
case key.Matches(msg, defaultKeyMap.Silence):
369376
return m, func() tea.Msg { return silenceSelectedIncidentMsg{} }
@@ -395,8 +402,12 @@ func switchIncidentFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
395402
}
396403
}
397404

398-
m.incidentViewer, cmd = m.incidentViewer.Update(msg)
399-
cmds = append(cmds, cmd)
405+
// Only pass the message to the viewport if we didn't handle it as a key command
406+
// This prevents the viewport from consuming ESC and other navigation keys
407+
if !handledKey {
408+
m.incidentViewer, cmd = m.incidentViewer.Update(msg)
409+
cmds = append(cmds, cmd)
410+
}
400411

401412
return m, tea.Batch(cmds...)
402413
}

0 commit comments

Comments
 (0)