mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5a8d0016a5
> Mux updated this PR on behalf of Mike. ## Stack Context This PR is the storage, permissions, API, and SDK layer for experimental personal skills. #25362 has landed on `main`, so this branch is restacked directly on `main`. Stack order: 1. #25363 storage, permissions, API, and SDK 2. #25365 API test coverage 3. #25366 chattool and chatd integration 4. #25066 settings UI and docs 5. #25386 personal skills slash menu ## What? Adds the `user_skills` database table, generated queries, RBAC resources and scopes, audit resource handling, experimental user-scoped CRUD endpoints, SDK types, and generated API/site types. Follow-up review and restack fixes: - Enforce a bounded personal skill description in parser and database constraints. - Return `403 Forbidden` for unauthorized create and update attempts. - Return explicit conflict responses when soft-deleted users are targeted. - Keep user admins out of personal skills, while site owners can read and delete but not create or update. - Document trigger-raised constraint names and keep schema constants covered by tests. - Reuse `UserSkillMetadata` in the full `UserSkill` SDK response type. - Generate user skill IDs in Go instead of relying on a database default. - Rebase on latest `main` and renumber the user skills migration to `000502_user_skills`. ## Why? Personal skills need durable user-owned storage with owner authorization, limited site-owner moderation, and a hidden API surface before chatd can consume them. ## Validation - `make gen` - `go test ./coderd/database -run '^TestUserSkillSchemaConstants$' -count=1` - `go test ./coderd/database/dbauthz -run '^TestMethodTestSuite/TestUserSkills$' -count=1` - `go test ./coderd -run '^TestPatchUserSkill$' -count=1` - `go test ./codersdk ./coderd/database/db2sdk` - `make lint` - pre-commit hook on `97fd58108d`
240 lines
7.2 KiB
Go
240 lines
7.2 KiB
Go
package skills
|
|
|
|
import (
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
// MaxPersonalSkillSizeBytes is the maximum raw Markdown size accepted for a
|
|
// personal skill upload.
|
|
const MaxPersonalSkillSizeBytes = workspacesdk.MaxSkillMetaBytes
|
|
|
|
// MaxPersonalSkillNameBytes is the maximum skill name length accepted for a
|
|
// personal skill upload. Skill names are also used in URL paths.
|
|
const MaxPersonalSkillNameBytes = 256
|
|
|
|
// MaxPersonalSkillDescriptionBytes is the maximum frontmatter description size
|
|
// accepted for a personal skill upload.
|
|
const MaxPersonalSkillDescriptionBytes = 4096
|
|
|
|
// MaxPersonalSkillsPerUser is the maximum number of personal skills a user may
|
|
// create.
|
|
const MaxPersonalSkillsPerUser = 100
|
|
|
|
// Source identifies where a skill came from.
|
|
type Source string
|
|
|
|
const (
|
|
// SourcePersonal identifies a user-owned, DB-backed skill.
|
|
SourcePersonal Source = "personal"
|
|
// SourceWorkspace identifies a filesystem-discovered workspace skill.
|
|
SourceWorkspace Source = "workspace"
|
|
)
|
|
|
|
var (
|
|
// ErrInvalidSkillName indicates that a skill name is missing, not valid
|
|
// kebab-case, or exceeds the maximum length.
|
|
ErrInvalidSkillName = xerrors.New("invalid skill name")
|
|
// ErrSkillBodyRequired indicates that the skill has no body after frontmatter.
|
|
ErrSkillBodyRequired = xerrors.New("skill body is required")
|
|
// ErrSkillTooLarge indicates that the raw skill Markdown is too large.
|
|
ErrSkillTooLarge = xerrors.New("skill is too large")
|
|
// ErrSkillDescriptionTooLarge indicates that the description is too large.
|
|
ErrSkillDescriptionTooLarge = xerrors.New("skill description is too large")
|
|
// ErrSkillNotFound indicates that a skill lookup did not match any alias.
|
|
ErrSkillNotFound = xerrors.New("skill not found")
|
|
// ErrSkillAmbiguous indicates that a skill lookup matched multiple sources.
|
|
ErrSkillAmbiguous = xerrors.New("skill lookup is ambiguous")
|
|
)
|
|
|
|
// Skill is the source-aware metadata needed to list and resolve a skill.
|
|
type Skill struct {
|
|
Name string
|
|
Description string
|
|
Source Source
|
|
}
|
|
|
|
// ParsedSkill is a parsed skill with the Markdown body after frontmatter.
|
|
// Body has HTML comments stripped and surrounding whitespace trimmed.
|
|
type ParsedSkill struct {
|
|
Skill
|
|
Body string
|
|
}
|
|
|
|
// ResolvedSkill is a skill with the alias exposed to chat tools.
|
|
type ResolvedSkill struct {
|
|
Skill
|
|
Alias string
|
|
}
|
|
|
|
// ParsePersonalSkillMarkdown parses raw personal skill Markdown and enforces
|
|
// the personal skill contract. The raw size must not exceed
|
|
// MaxPersonalSkillSizeBytes, frontmatter must contain a valid kebab-case name,
|
|
// the skill name must not exceed MaxPersonalSkillNameBytes, the description must
|
|
// not exceed MaxPersonalSkillDescriptionBytes, and the body after frontmatter
|
|
// must be non-empty.
|
|
func ParsePersonalSkillMarkdown(raw []byte) (ParsedSkill, error) {
|
|
if len(raw) > MaxPersonalSkillSizeBytes {
|
|
return ParsedSkill{}, xerrors.Errorf(
|
|
"%w: got %d bytes, maximum is %d bytes",
|
|
ErrSkillTooLarge,
|
|
len(raw),
|
|
MaxPersonalSkillSizeBytes,
|
|
)
|
|
}
|
|
|
|
name, description, body, err := workspacesdk.ParseSkillFrontmatter(string(raw))
|
|
if err != nil {
|
|
if xerrors.Is(err, workspacesdk.ErrFrontmatterNameRequired) {
|
|
return ParsedSkill{}, xerrors.Errorf("%w: frontmatter must contain a 'name' field", ErrInvalidSkillName)
|
|
}
|
|
return ParsedSkill{}, xerrors.Errorf("parse skill frontmatter: %w", err)
|
|
}
|
|
if !workspacesdk.SkillNamePattern.MatchString(name) {
|
|
return ParsedSkill{}, xerrors.Errorf(
|
|
"%w: %q must match %s",
|
|
ErrInvalidSkillName,
|
|
name,
|
|
workspacesdk.SkillNameRegex,
|
|
)
|
|
}
|
|
nameBytes := len(name)
|
|
if nameBytes > MaxPersonalSkillNameBytes {
|
|
return ParsedSkill{}, xerrors.Errorf(
|
|
"%w: %q is %d bytes, maximum is %d bytes",
|
|
ErrInvalidSkillName,
|
|
name,
|
|
nameBytes,
|
|
MaxPersonalSkillNameBytes,
|
|
)
|
|
}
|
|
descriptionBytes := len(description)
|
|
if descriptionBytes > MaxPersonalSkillDescriptionBytes {
|
|
return ParsedSkill{}, xerrors.Errorf(
|
|
"%w: got %d bytes, maximum is %d bytes",
|
|
ErrSkillDescriptionTooLarge,
|
|
descriptionBytes,
|
|
MaxPersonalSkillDescriptionBytes,
|
|
)
|
|
}
|
|
if strings.TrimSpace(body) == "" {
|
|
return ParsedSkill{}, xerrors.Errorf(
|
|
"%w: skill %q has no content after frontmatter",
|
|
ErrSkillBodyRequired,
|
|
name,
|
|
)
|
|
}
|
|
|
|
return ParsedSkill{
|
|
Skill: Skill{
|
|
Name: name,
|
|
Description: description,
|
|
Source: SourcePersonal,
|
|
},
|
|
Body: body,
|
|
}, nil
|
|
}
|
|
|
|
// MergeSkills combines personal and workspace skills into a deterministic list
|
|
// with aliases for chat tool display and lookup. Skill names must already be
|
|
// valid kebab-case names because qualified aliases use / as a separator. If a
|
|
// source contains duplicate names, the first skill for that source wins.
|
|
func MergeSkills(personalSkills, workspaceSkills []Skill) []ResolvedSkill {
|
|
personalByName := skillsByName(personalSkills, SourcePersonal)
|
|
workspaceByName := skillsByName(workspaceSkills, SourceWorkspace)
|
|
|
|
names := make(map[string]struct{}, len(personalByName)+len(workspaceByName))
|
|
for name := range personalByName {
|
|
names[name] = struct{}{}
|
|
}
|
|
for name := range workspaceByName {
|
|
names[name] = struct{}{}
|
|
}
|
|
|
|
resolved := make([]ResolvedSkill, 0, len(personalByName)+len(workspaceByName))
|
|
for _, name := range slices.Sorted(maps.Keys(names)) {
|
|
personal, hasPersonal := personalByName[name]
|
|
workspace, hasWorkspace := workspaceByName[name]
|
|
if hasPersonal && hasWorkspace {
|
|
resolved = append(resolved,
|
|
ResolvedSkill{
|
|
Skill: personal,
|
|
Alias: QualifiedAlias(SourcePersonal, name),
|
|
},
|
|
ResolvedSkill{
|
|
Skill: workspace,
|
|
Alias: QualifiedAlias(SourceWorkspace, name),
|
|
},
|
|
)
|
|
continue
|
|
}
|
|
if hasPersonal {
|
|
resolved = append(resolved, ResolvedSkill{
|
|
Skill: personal,
|
|
Alias: name,
|
|
})
|
|
continue
|
|
}
|
|
resolved = append(resolved, ResolvedSkill{
|
|
Skill: workspace,
|
|
Alias: name,
|
|
})
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// Lookup finds a resolved skill by bare alias or qualified source alias. It
|
|
// returns ErrSkillNotFound if no alias matches, or ErrSkillAmbiguous if a bare
|
|
// name matches skills from multiple sources.
|
|
func Lookup(resolved []ResolvedSkill, lookup string) (ResolvedSkill, error) {
|
|
var (
|
|
bareNameMatch ResolvedSkill
|
|
matches []string
|
|
)
|
|
for _, skill := range resolved {
|
|
qualifiedAlias := QualifiedAlias(skill.Source, skill.Name)
|
|
if lookup == skill.Alias || lookup == qualifiedAlias {
|
|
return skill, nil
|
|
}
|
|
if lookup == skill.Name {
|
|
bareNameMatch = skill
|
|
matches = append(matches, qualifiedAlias)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return ResolvedSkill{}, xerrors.Errorf("%w: %q", ErrSkillNotFound, lookup)
|
|
case 1:
|
|
return bareNameMatch, nil
|
|
default:
|
|
return ResolvedSkill{}, xerrors.Errorf(
|
|
"%w: %q matches %s",
|
|
ErrSkillAmbiguous,
|
|
lookup,
|
|
strings.Join(matches, ", "),
|
|
)
|
|
}
|
|
}
|
|
|
|
// QualifiedAlias returns the stable source-qualified alias for a skill name.
|
|
func QualifiedAlias(source Source, name string) string {
|
|
return string(source) + "/" + name
|
|
}
|
|
|
|
func skillsByName(skills []Skill, source Source) map[string]Skill {
|
|
byName := make(map[string]Skill, len(skills))
|
|
for _, skill := range skills {
|
|
if _, ok := byName[skill.Name]; ok {
|
|
continue
|
|
}
|
|
skill.Source = source
|
|
byName[skill.Name] = skill
|
|
}
|
|
return byName
|
|
}
|