mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
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
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
)
|
||||
|
||||
func TestValidateChatLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NilMap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
errs := httpapi.ValidateChatLabels(nil)
|
||||
require.Empty(t, errs)
|
||||
})
|
||||
|
||||
t.Run("EmptyMap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
errs := httpapi.ValidateChatLabels(map[string]string{})
|
||||
require.Empty(t, errs)
|
||||
})
|
||||
|
||||
t.Run("ValidLabels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"env": "production",
|
||||
"github.repo": "coder/coder",
|
||||
"automation/pr": "12345",
|
||||
"team-backend": "core",
|
||||
"version_number": "v1.2.3",
|
||||
"A1.b2/c3-d4_e5": "mixed",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.Empty(t, errs)
|
||||
})
|
||||
|
||||
t.Run("TooManyLabels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := make(map[string]string, 51)
|
||||
for i := range 51 {
|
||||
labels[strings.Repeat("k", i+1)] = "v"
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.NotEmpty(t, errs)
|
||||
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Detail, "too many labels") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected a 'too many labels' error")
|
||||
})
|
||||
|
||||
t.Run("KeyTooLong", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
longKey := strings.Repeat("a", 65)
|
||||
labels := map[string]string{
|
||||
longKey: "value",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.NotEmpty(t, errs)
|
||||
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Detail, "exceeds maximum length of 64 bytes") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected a key-too-long error")
|
||||
})
|
||||
|
||||
t.Run("ValueTooLong", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
longValue := strings.Repeat("v", 257)
|
||||
labels := map[string]string{
|
||||
"key": longValue,
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.NotEmpty(t, errs)
|
||||
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Detail, "exceeds maximum length of 256 bytes") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected a value-too-long error")
|
||||
})
|
||||
|
||||
t.Run("InvalidKeyWithSpaces", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"invalid key": "value",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.NotEmpty(t, errs)
|
||||
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Detail, "contains invalid characters") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected an invalid-characters error for spaces")
|
||||
})
|
||||
|
||||
t.Run("InvalidKeyWithSpecialChars", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"key@value": "value",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.NotEmpty(t, errs)
|
||||
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Detail, "contains invalid characters") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected an invalid-characters error for special chars")
|
||||
})
|
||||
|
||||
t.Run("KeyStartsWithNonAlphanumeric", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
".dotfirst": "value",
|
||||
"-dashfirst": "value",
|
||||
"_underfirst": "value",
|
||||
"/slashfirst": "value",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
// Each of the four keys should produce an error.
|
||||
require.Len(t, errs, 4)
|
||||
for _, e := range errs {
|
||||
assert.Contains(t, e.Detail, "contains invalid characters")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptyKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"": "value",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.Len(t, errs, 1)
|
||||
assert.Contains(t, errs[0].Detail, "must not be empty")
|
||||
})
|
||||
|
||||
t.Run("EmptyValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"key": "",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.Len(t, errs, 1)
|
||||
assert.Contains(t, errs[0].Detail, "must not be empty")
|
||||
})
|
||||
|
||||
t.Run("AllFieldsAreLabels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"bad key": "",
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
for _, e := range errs {
|
||||
assert.Equal(t, "labels", e.Field)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExactlyAtLimits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Keys and values exactly at their limits should be valid.
|
||||
labels := map[string]string{
|
||||
strings.Repeat("a", 64): strings.Repeat("v", 256),
|
||||
}
|
||||
errs := httpapi.ValidateChatLabels(labels)
|
||||
require.Empty(t, errs)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user