diff --git a/adapters/linear/harden_capability_test.go b/adapters/linear/harden_capability_test.go index 53d9329..fd1493a 100644 --- a/adapters/linear/harden_capability_test.go +++ b/adapters/linear/harden_capability_test.go @@ -205,8 +205,7 @@ func TestRateLimitDeadlineReturnsTypedRateLimited(t *testing.T) { select { case err := <-done: - var rl *linear.RateLimited - if !errors.As(err, &rl) { + if _, ok := errors.AsType[*linear.RateLimited](err); !ok { t.Fatalf("err = %v, want *linear.RateLimited (deadline-bounded, not slept off)", err) } if elapsed := time.Since(start); elapsed > time.Second { diff --git a/adapters/linear/history.go b/adapters/linear/history.go index 1f0826e..6985430 100644 --- a/adapters/linear/history.go +++ b/adapters/linear/history.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "github.com/coder/chat" ) @@ -119,8 +120,7 @@ func (a *Adapter) readAgentSessionHistory(ctx context.Context, thread threadPayl nodes := resp.Data.AgentSession.Activities.Nodes messages := make([]chat.Message, 0, len(nodes)) // Linear returns the page createdAt-ascending; reverse for newest-first. - for i := len(nodes) - 1; i >= 0; i-- { - node := nodes[i] + for _, node := range slices.Backward(nodes) { messages = append(messages, chat.Message{ ID: node.ID, Text: node.Content.Body, @@ -164,8 +164,8 @@ func (a *Adapter) readCommentThreadHistory(ctx context.Context, thread threadPay children := resp.Data.Thread.Children.Nodes messages := make([]chat.Message, 0, len(children)+1) // Linear returns the page createdAt-ascending; reverse for newest-first. - for i := len(children) - 1; i >= 0; i-- { - messages = append(messages, historyCommentMessage(thread.Organization, children[i])) + for _, c := range slices.Backward(children) { + messages = append(messages, historyCommentMessage(thread.Organization, c)) } // No older replies remain, so the root comment closes the oldest page. if !resp.Data.Thread.Children.PageInfo.HasPreviousPage { diff --git a/adapters/linear/history_test.go b/adapters/linear/history_test.go index 2dd6858..8cac481 100644 --- a/adapters/linear/history_test.go +++ b/adapters/linear/history_test.go @@ -623,8 +623,7 @@ func TestLinearReadHistoryRateLimitObservedAndErrors(t *testing.T) { id := linear.EncodeAgentSessionThreadIDForTest("ORG1", "ISSUE1", "S1") msgs, err := hr.ReadHistory(context.Background(), id, chat.HistoryQuery{}) - var limited *linear.RateLimited - if !errors.As(err, &limited) { + if _, ok := errors.AsType[*linear.RateLimited](err); !ok { t.Fatalf("err = %v, want *linear.RateLimited on a throttled read", err) } if msgs != nil { diff --git a/adapters/linear/linear.go b/adapters/linear/linear.go index c6bbe5e..9363fa7 100644 --- a/adapters/linear/linear.go +++ b/adapters/linear/linear.go @@ -246,8 +246,7 @@ func (a *Adapter) Webhook(dispatch chat.DispatchFunc) http.Handler { } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)) if err != nil { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { http.Error(w, "linear webhook too large", http.StatusRequestEntityTooLarge) return } @@ -926,10 +925,11 @@ func encodeThreadID(payload threadPayload) (chat.ThreadID, error) { func decodeThreadID(id chat.ThreadID) (threadPayload, error) { const prefix = "linear:v1:" - if !strings.HasPrefix(string(id), prefix) { + rest, ok := strings.CutPrefix(string(id), prefix) + if !ok { return threadPayload{}, fmt.Errorf("linear: malformed thread id %q", id) } - body, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(string(id), prefix)) + body, err := base64.RawURLEncoding.DecodeString(rest) if err != nil { return threadPayload{}, fmt.Errorf("linear: decode thread id: %w", err) } @@ -1179,7 +1179,7 @@ func verifyGrantedScopes(requested []string, granted string) error { func parseGrantedScopes(value string) map[string]struct{} { out := map[string]struct{}{} - for _, scope := range strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) { + for scope := range strings.FieldsFuncSeq(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) { scope = strings.TrimSpace(scope) if scope != "" { out[scope] = struct{}{} diff --git a/adapters/linear/linear_test.go b/adapters/linear/linear_test.go index 0dde811..0c17aab 100644 --- a/adapters/linear/linear_test.go +++ b/adapters/linear/linear_test.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/http/httptest" + "slices" "strings" "sync" "testing" @@ -684,10 +685,8 @@ func containsToken(v any) bool { } } case []any: - for _, val := range t { - if containsToken(val) { - return true - } + if slices.ContainsFunc(t, containsToken) { + return true } } return false diff --git a/adapters/slack/history.go b/adapters/slack/history.go index 5b8b9c9..e953442 100644 --- a/adapters/slack/history.go +++ b/adapters/slack/history.go @@ -112,9 +112,9 @@ func (a *Adapter) ReadHistory(ctx context.Context, id chat.ThreadID, q chat.Hist type conversationsReadPayload struct { Channel string `json:"channel"` TS string `json:"ts,omitempty"` - Limit int `json:"limit,omitempty"` + Limit int `json:"limit,omitzero"` Latest string `json:"latest,omitempty"` - Inclusive bool `json:"inclusive,omitempty"` + Inclusive bool `json:"inclusive,omitzero"` } type conversationsHistoryResponse struct { diff --git a/adapters/slack/history_hardening_test.go b/adapters/slack/history_hardening_test.go index e7c39fd..4f21f82 100644 --- a/adapters/slack/history_hardening_test.go +++ b/adapters/slack/history_hardening_test.go @@ -123,8 +123,7 @@ func TestSlackReadHistoryRateLimitObservedAndErrors(t *testing.T) { id := slack.EncodeThreadReplyThreadIDForTest("T1", "C1", "111.000") msgs, err := hr.ReadHistory(context.Background(), id, chat.HistoryQuery{Limit: 5}) - var limited *slack.RateLimited - if !errors.As(err, &limited) { + if _, ok := errors.AsType[*slack.RateLimited](err); !ok { t.Fatalf("err = %v, want *slack.RateLimited on a throttled read", err) } if msgs != nil { diff --git a/adapters/slack/interactive_hardening_test.go b/adapters/slack/interactive_hardening_test.go index adce60e..02aa169 100644 --- a/adapters/slack/interactive_hardening_test.go +++ b/adapters/slack/interactive_hardening_test.go @@ -57,7 +57,7 @@ func TestSlackCommandDedupedByEventIdentity(t *testing.T) { } form := signedCommandForm() - for i := 0; i < 3; i++ { + for i := range 3 { rec := serveSignedSlackForm(t, handler, now, form) if rec.Code != http.StatusOK { t.Fatalf("delivery %d status = %d", i, rec.Code) @@ -100,7 +100,7 @@ func TestSlackInteractionDedupedByEventIdentity(t *testing.T) { "response_url":"https://hooks.slack.com/actions/T1/999", "actions":[{"action_id":"approve","block_id":"b1","value":"yes","type":"button"}] }` - for i := 0; i < 3; i++ { + for i := range 3 { rec := serveSignedSlackInteractivity(t, handler, now, payload) if rec.Code != http.StatusOK { t.Fatalf("delivery %d status = %d", i, rec.Code) @@ -154,7 +154,7 @@ func TestSlackRepeatInteractionsAreDistinctEvents(t *testing.T) { for _, actionTS := range []string{"1700000001.000100", "1700000002.000200", "1700000003.000300"} { payload := activation(actionTS) // Deliver each activation twice: the original and a redelivery. - for delivery := 0; delivery < 2; delivery++ { + for delivery := range 2 { rec := serveSignedSlackInteractivity(t, handler, now, payload) if rec.Code != http.StatusOK { t.Fatalf("activation %s delivery %d status = %d", actionTS, delivery, rec.Code) diff --git a/adapters/slack/multitenant_test.go b/adapters/slack/multitenant_test.go index d55b174..91eef5a 100644 --- a/adapters/slack/multitenant_test.go +++ b/adapters/slack/multitenant_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "slices" "sync" "testing" "time" @@ -57,12 +58,7 @@ func (s *fakeInstallStore) callCount() int { func (s *fakeInstallStore) lookedUp(tenant string) bool { s.mu.Lock() defer s.mu.Unlock() - for _, c := range s.calls { - if c == "slack:"+tenant { - return true - } - } - return false + return slices.Contains(s.calls, "slack:"+tenant) } func TestSlackConstructionModeSelection(t *testing.T) { @@ -400,15 +396,13 @@ func TestSlackMultiTenantConcurrentTenantsDoNotCross(t *testing.T) { } var wg sync.WaitGroup for _, team := range []string{"T1", "T2"} { - wg.Add(1) - go func(team string) { - defer wg.Done() + wg.Go(func() { rec := serveSignedSlackWebhook(t, handler, now, slackEventBody(team, "Ev"+team, "U"+team, "<@UBOT"+team[1:]+"> hi"), "", "") if rec.Code != http.StatusOK { t.Errorf("team %s status = %d", team, rec.Code) } - }(team) + }) } wg.Wait() diff --git a/adapters/slack/ratelimit_hardening_test.go b/adapters/slack/ratelimit_hardening_test.go index 9e2bb04..1c72a4f 100644 --- a/adapters/slack/ratelimit_hardening_test.go +++ b/adapters/slack/ratelimit_hardening_test.go @@ -258,8 +258,7 @@ func TestSlackPostNeverSleepsPastContextDeadline(t *testing.T) { select { case err := <-done: - var limited *slack.RateLimited - if !errors.As(err, &limited) { + if _, ok := errors.AsType[*slack.RateLimited](err); !ok { t.Fatalf("err = %v, want *slack.RateLimited (deadline-bounded, not slept off)", err) } case <-time.After(5 * time.Second): @@ -306,8 +305,7 @@ func TestSlackPostDoesNotRetryNonThrottlingError(t *testing.T) { if postErr == nil { t.Fatal("expected an error from a 401 auth failure") } - var limited *slack.RateLimited - if errors.As(postErr, &limited) { + if _, ok := errors.AsType[*slack.RateLimited](postErr); ok { t.Fatal("a non-throttling 401 must not surface as RateLimited") } mu.Lock() @@ -337,8 +335,7 @@ func TestSlackDefaultPolicyCeilingUnderAckWindow(t *testing.T) { _, err := adapter.PostMessage(context.Background(), ref, chat.Text("hi")) elapsed := time.Since(start) - var limited *slack.RateLimited - if !errors.As(err, &limited) { + if _, ok := errors.AsType[*slack.RateLimited](err); !ok { t.Fatalf("err = %v, want *slack.RateLimited", err) } if elapsed >= 3*time.Second { diff --git a/adapters/slack/slack.go b/adapters/slack/slack.go index f678f00..1150e2d 100644 --- a/adapters/slack/slack.go +++ b/adapters/slack/slack.go @@ -222,8 +222,7 @@ func (a *Adapter) Webhook(dispatch chat.DispatchFunc) http.Handler { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)) if err != nil { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { http.Error(w, "slack payload too large", http.StatusRequestEntityTooLarge) return } @@ -731,7 +730,7 @@ type threadPayload struct { Team string `json:"team"` Channel string `json:"channel"` Root string `json:"root,omitempty"` - Direct bool `json:"direct,omitempty"` + Direct bool `json:"direct,omitzero"` } func encodeThreadID(payload threadPayload) (chat.ThreadID, error) { @@ -753,10 +752,11 @@ func encodeThreadID(payload threadPayload) (chat.ThreadID, error) { func decodeThreadID(id chat.ThreadID) (threadPayload, error) { const prefix = "slack:v1:" - if !strings.HasPrefix(string(id), prefix) { + rest, ok := strings.CutPrefix(string(id), prefix) + if !ok { return threadPayload{}, fmt.Errorf("slack: malformed thread id %q", id) } - body, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(string(id), prefix)) + body, err := base64.RawURLEncoding.DecodeString(rest) if err != nil { return threadPayload{}, fmt.Errorf("slack: decode thread id: %w", err) } diff --git a/adapters/slack/slack_test.go b/adapters/slack/slack_test.go index 44d9767..a6f9e4b 100644 --- a/adapters/slack/slack_test.go +++ b/adapters/slack/slack_test.go @@ -470,7 +470,7 @@ func TestPostingTextMarkdownEphemeralAndExplicitFallback(t *testing.T) { t.Fatalf("fallback sent = %#v", sent) } - api.assertPost(t, 0, slackPost{Channel: "C1", ThreadTS: "111.000", Text: "plain reply", Mrkdwn: boolPtr(false)}) + api.assertPost(t, 0, slackPost{Channel: "C1", ThreadTS: "111.000", Text: "plain reply", Mrkdwn: new(false)}) api.assertPost(t, 1, slackPost{Channel: "C1", ThreadTS: "111.000", MarkdownText: "**portable**"}) api.assertPost(t, 2, slackPost{Channel: "D-fallback", MarkdownText: "**private fallback**"}) } @@ -740,10 +740,6 @@ func (s *slackAPIServer) authForPost(t *testing.T, index int) string { return s.postAuth[index] } -func boolPtr(value bool) *bool { - return &value -} - func decodeJSON(t *testing.T, body io.Reader, dest any) { t.Helper() if err := json.NewDecoder(body).Decode(dest); err != nil { diff --git a/admission_test.go b/admission_test.go index 74d1846..b86207a 100644 --- a/admission_test.go +++ b/admission_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "slices" "strings" "sync" "testing" @@ -169,12 +170,7 @@ func TestAdmissionRejectsAtMaxDetachedWithoutDedupeMark(t *testing.T) { return status == http.StatusOK }, "capacity did not free after handler completion") eventually(t, 5*time.Second, func() bool { - for _, id := range handlers.handledIDs() { - if id == "event-2" { - return true - } - } - return false + return slices.Contains(handlers.handledIDs(), "event-2") }, "redelivered rejected event was not handled (dedupe-marked before rejection?)") } @@ -611,12 +607,7 @@ func TestAdmissionCountsParkedQueueWaiters(t *testing.T) { handlers.release() eventually(t, 5*time.Second, func() bool { - for _, id := range handlers.handledIDs() { - if id == "event-2" { - return true - } - } - return false + return slices.Contains(handlers.handledIDs(), "event-2") }, "queued waiter did not run after the in-flight handler finished") } @@ -659,12 +650,7 @@ func TestAdmissionCountsConcurrentSlotWaiters(t *testing.T) { // hasOutcome reports whether the observer recorded the terminal outcome. func hasOutcome(observer *recordingObserver, want chat.DispatchOutcome) bool { - for _, outcome := range observer.terminalOutcomes() { - if outcome == want { - return true - } - } - return false + return slices.Contains(observer.terminalOutcomes(), want) } // assertAdmissionRejectedAttrs verifies the admission_rejected observation diff --git a/burst.go b/burst.go index a7b35f5..db7edf8 100644 --- a/burst.go +++ b/burst.go @@ -220,7 +220,7 @@ func (c *Chat) dispatchBurstBatch(scope string, batch []preludeWork) { // finalRelease is the last member's admission release, deferred past the // lock cleanup below. var finalRelease func() - for i := 0; i < len(batch); i++ { + for i := range batch { work := batch[i] if batchCtx.Err() != nil { // Lease loss or Runtime Shutdown: members that never started are diff --git a/burst_hardening_test.go b/burst_hardening_test.go index 97226c1..45c1936 100644 --- a/burst_hardening_test.go +++ b/burst_hardening_test.go @@ -830,13 +830,11 @@ func TestBurstDispatchRacingShutdownRejectedOrDrained(t *testing.T) { statuses := make([]int, deliveries) bodies := make([]string, deliveries) var wg sync.WaitGroup - for i := 0; i < deliveries; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() + for i := range deliveries { + wg.Go(func() { id := "race-event-" + string(rune('a'+i)) statuses[i], bodies[i] = postEventBody(t, bot, "fake", mentionEvent(id, "fake:v1:thread-race")) - }(i) + }) } time.Sleep(10 * time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/command_interaction_hardening_test.go b/command_interaction_hardening_test.go index 8f7fa89..c90581d 100644 --- a/command_interaction_hardening_test.go +++ b/command_interaction_hardening_test.go @@ -52,13 +52,13 @@ func TestCommandAndInteractionDedupedByEventIdentity(t *testing.T) { }) cmd := commandEvent("cmd-dup", "fake:v1:thread-cmd") - for i := 0; i < 3; i++ { + for i := range 3 { if status := postEvent(t, bot, "fake", cmd); status != http.StatusOK { t.Fatalf("command delivery %d status = %d", i, status) } } intr := interactionEvent("int-dup", "fake:v1:thread-int") - for i := 0; i < 3; i++ { + for i := range 3 { if status := postEvent(t, bot, "fake", intr); status != http.StatusOK { t.Fatalf("interaction delivery %d status = %d", i, status) } diff --git a/dispatch_deferred_test.go b/dispatch_deferred_test.go index 0d768bb..346c9ff 100644 --- a/dispatch_deferred_test.go +++ b/dispatch_deferred_test.go @@ -604,14 +604,12 @@ func TestQueueStrategyDispatchesMostRecentSupersededFollowUp(t *testing.T) { // recent dispatches, the older two are superseded. var wg sync.WaitGroup for _, id := range []string{"q1", "q2", "q3"} { - wg.Add(1) - go func(id string) { - defer wg.Done() + wg.Go(func() { res := postEventResultFor(bot, "fake", mentionEvent(id, "fake:v1:thread-1")) if res.err != nil || res.status != http.StatusOK { t.Errorf("follow-up %s status=%d err=%v", id, res.status, res.err) } - }(id) + }) // Stagger so the supersession order is deterministic (q3 is newest). time.Sleep(10 * time.Millisecond) } diff --git a/history_hardening_test.go b/history_hardening_test.go index 73b7fa5..31c95e5 100644 --- a/history_hardening_test.go +++ b/history_hardening_test.go @@ -19,7 +19,7 @@ import ( // reached only through Adapter Access, never on the dispatch path (ADR 0009). type dispatchingHistoryAdapter struct { name string - historyHits int32 + historyHits atomic.Int32 } func (a *dispatchingHistoryAdapter) Name() string { return a.name } @@ -54,7 +54,7 @@ func (a *dispatchingHistoryAdapter) BotActor() chat.Actor { } func (a *dispatchingHistoryAdapter) ReadHistory(context.Context, chat.ThreadID, chat.HistoryQuery) ([]chat.Message, error) { - atomic.AddInt32(&a.historyHits, 1) + a.historyHits.Add(1) return nil, nil } @@ -73,9 +73,9 @@ func TestHistoryReaderNotInvokedDuringDispatch(t *testing.T) { t.Fatalf("new runtime: %v", err) } - var handled int32 + var handled atomic.Int32 bot.OnNewMention(func(context.Context, *chat.MessageEvent) error { - atomic.AddInt32(&handled, 1) + handled.Add(1) return nil }) @@ -107,10 +107,10 @@ func TestHistoryReaderNotInvokedDuringDispatch(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("dispatch status = %d, body = %s", rec.Code, rec.Body.String()) } - if got := atomic.LoadInt32(&handled); got != 1 { + if got := handled.Load(); got != 1 { t.Fatalf("handler invocations = %d, want 1 (event must route)", got) } - if got := atomic.LoadInt32(&adapter.historyHits); got != 0 { + if got := adapter.historyHits.Load(); got != 0 { t.Fatalf("ReadHistory invoked %d times during dispatch, want 0", got) } } diff --git a/history_test.go b/history_test.go index 55628e7..934576d 100644 --- a/history_test.go +++ b/history_test.go @@ -116,14 +116,14 @@ func TestHistoryReaderCapabilityDetection(t *testing.T) { func TestHistoryHasNoCoreSurface(t *testing.T) { t.Parallel() - chatType := reflect.TypeOf(&chat.Chat{}) + chatType := reflect.TypeFor[*chat.Chat]() for _, name := range []string{"ReadHistory", "History"} { if _, ok := chatType.MethodByName(name); ok { t.Fatalf("*chat.Chat must not expose %s; history is Adapter-Access only", name) } } - threadType := reflect.TypeOf(&chat.Thread{}) + threadType := reflect.TypeFor[*chat.Thread]() for _, name := range []string{"ReadHistory", "History"} { if _, ok := threadType.MethodByName(name); ok { t.Fatalf("*chat.Thread must not expose %s; history is Adapter-Access only", name) diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index c9afef3..9c5e469 100644 --- a/internal/ratelimit/ratelimit.go +++ b/internal/ratelimit/ratelimit.go @@ -41,20 +41,15 @@ func CloneRequest(req *http.Request, body []byte) *http.Request { } // BackoffDelay computes the next backoff: exponential from base (doubling per -// attempt) capped at max, overridden by retryAfter when the platform signals a -// longer wait, then clamped to max. -func BackoffDelay(base, max time.Duration, attempt int, retryAfter time.Duration) time.Duration { +// attempt) capped at maxDelay, overridden by retryAfter when the platform +// signals a longer wait, then clamped to maxDelay. +func BackoffDelay(base, maxDelay time.Duration, attempt int, retryAfter time.Duration) time.Duration { delay := base << (attempt - 1) - if delay <= 0 || delay > max { - delay = max + if delay <= 0 || delay > maxDelay { + // Shift overflow (or wrap to non-positive) also lands on the cap. + delay = maxDelay } - if retryAfter > delay { - delay = retryAfter - } - if delay > max { - delay = max - } - return delay + return min(max(delay, retryAfter), maxDelay) } // SleepCtx sleeps for d but returns the context error if the context ends first, diff --git a/observer_test.go b/observer_test.go index bf64998..17fc846 100644 --- a/observer_test.go +++ b/observer_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "slices" "sync" "testing" "time" @@ -50,12 +51,7 @@ func (o *recordingObserver) eventNames() []chat.ObservationName { } func (o *recordingObserver) hasEvent(name chat.ObservationName) bool { - for _, got := range o.eventNames() { - if got == name { - return true - } - } - return false + return slices.Contains(o.eventNames(), name) } func (o *recordingObserver) terminalOutcomes() []chat.DispatchOutcome { diff --git a/queue_edge_test.go b/queue_edge_test.go index d5f1fac..0f39e07 100644 --- a/queue_edge_test.go +++ b/queue_edge_test.go @@ -159,14 +159,12 @@ func TestQueueSupersededEventSurfacesSupersededByObservation(t *testing.T) { // "older" queues first, then "newer" supersedes it. var wg sync.WaitGroup for _, id := range []string{"older", "newer"} { - wg.Add(1) - go func(id string) { - defer wg.Done() + wg.Go(func() { res := postEventResultFor(bot, "fake", mentionEvent(id, "fake:v1:thread-1")) if res.err != nil || res.status != http.StatusOK { t.Errorf("follow-up %s status=%d err=%v", id, res.status, res.err) } - }(id) + }) time.Sleep(15 * time.Millisecond) }