Files
coder/coderd/httpapi/chatlabels.go
T
Kyle Carberry d4660d8a69 feat: add labels to chats (#23594)
## Summary

Adds a general-purpose `map[string]string` label system to chats, stored
as jsonb with a GIN index for efficient containment queries.

This is a standalone foundational feature that will be used by the
upcoming Automations feature for session identity (matching webhook
events to existing chats), replacing the need for bespoke session-key
tables.

## Changes

### Database
- **Migration 000451**: Adds `labels jsonb NOT NULL DEFAULT '{}'` column
to `chats` table with a GIN index (`idx_chats_labels`)
- **`InsertChat`**: Accepts labels on creation via `COALESCE(@labels,
'{}')`
- **`UpdateChatByID`**: Supports partial update —
`COALESCE(sqlc.narg('labels'), labels)` preserves existing labels when
NULL is passed
- **`GetChats`**: New `has_labels` filter using PostgreSQL `@>`
containment operator
- **`GetAuthorizedChats`**: Synced with generated `GetChats` (new column
scan + query param)

### API
- **Create chat** (`POST /chats`): Accepts optional `labels` field,
validated before creation
- **Update chat** (`PATCH /chats/{chat}`): Supports `labels` field for
atomic label replacement
- **List chats** (`GET /chats`): Supports `?label=key:value` query
parameters (multiple are AND-ed)

### SDK
- `Chat`, `CreateChatRequest`, `UpdateChatRequest`, `ListChatsOptions`
all gain `Labels` fields
- `UpdateChatRequest.Labels` is a pointer (`*map[string]string`) so
`nil` means "don't change" vs empty map means "clear all"

### Validation (`coderd/httpapi/labels.go`)
- Max 50 labels per chat
- Key: 1–64 chars, must match `[a-zA-Z0-9][a-zA-Z0-9._/-]*` (supports
namespaced keys like `github.repo`, `automation/pr-number`)
- Value: 1–256 chars
- 13 test cases covering all edge cases

### Chat runtime
- `chatd.CreateOptions` gains `Labels` field, threaded through to
`InsertChat`
- Existing `UpdateChatByID` callers (e.g., quickgen title updates) are
unaffected — NULL labels preserve existing values via COALESCE
2026-03-25 17:26:26 +00:00

79 lines
2.1 KiB
Go

package httpapi
import (
"fmt"
"regexp"
"github.com/coder/coder/v2/codersdk"
)
const (
// maxLabelsPerChat is the maximum number of labels allowed on a
// single chat.
maxLabelsPerChat = 50
// maxLabelKeyLength is the maximum length of a label key in bytes.
maxLabelKeyLength = 64
// maxLabelValueLength is the maximum length of a label value in
// bytes.
maxLabelValueLength = 256
)
// labelKeyRegex validates that a label key starts with an alphanumeric
// character and is followed by alphanumeric characters, dots, hyphens,
// underscores, or forward slashes.
var labelKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
// ValidateChatLabels checks that the provided labels map conforms to the
// labeling constraints for chats. It returns a list of validation
// errors, one per violated constraint.
func ValidateChatLabels(labels map[string]string) []codersdk.ValidationError {
var errs []codersdk.ValidationError
if len(labels) > maxLabelsPerChat {
errs = append(errs, codersdk.ValidationError{
Field: "labels",
Detail: fmt.Sprintf("too many labels (%d); maximum is %d", len(labels), maxLabelsPerChat),
})
}
for k, v := range labels {
if k == "" {
errs = append(errs, codersdk.ValidationError{
Field: "labels",
Detail: "label key must not be empty",
})
continue
}
if len(k) > maxLabelKeyLength {
errs = append(errs, codersdk.ValidationError{
Field: "labels",
Detail: fmt.Sprintf("label key %q exceeds maximum length of %d bytes", k, maxLabelKeyLength),
})
}
if !labelKeyRegex.MatchString(k) {
errs = append(errs, codersdk.ValidationError{
Field: "labels",
Detail: fmt.Sprintf("label key %q contains invalid characters; must match %s", k, labelKeyRegex.String()),
})
}
if v == "" {
errs = append(errs, codersdk.ValidationError{
Field: "labels",
Detail: fmt.Sprintf("label value for key %q must not be empty", k),
})
}
if len(v) > maxLabelValueLength {
errs = append(errs, codersdk.ValidationError{
Field: "labels",
Detail: fmt.Sprintf("label value for key %q exceeds maximum length of %d bytes", k, maxLabelValueLength),
})
}
}
return errs
}