// Copyright 2026 Moko Consulting // SPDX-License-Identifier: GPL-3.0-or-later package issues import ( "context" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" ) func init() { db.RegisterModel(new(CustomFieldDef)) db.RegisterModel(new(CustomFieldValue)) } // CustomFieldType represents the data type of a custom field. type CustomFieldType string const ( CustomFieldTypeText CustomFieldType = "text" CustomFieldTypeNumber CustomFieldType = "number" CustomFieldTypeDate CustomFieldType = "date" CustomFieldTypeDropdown CustomFieldType = "dropdown" CustomFieldTypeCheckbox CustomFieldType = "checkbox" CustomFieldTypeURL CustomFieldType = "url" ) // CustomFieldScope determines where the field appears. type CustomFieldScope string const ( CustomFieldScopeIssue CustomFieldScope = "issue" // appears in issue sidebar CustomFieldScopeRepo CustomFieldScope = "repo" // appears in repo settings metadata ) // CustomFieldDef defines a custom field at the org level. // owner_id = org ID, scope = issue or repo. // repo_id is kept for backward compat but 0 for org-level definitions. type CustomFieldDef struct { ID int64 `xorm:"pk autoincr"` OwnerID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'owner_id'"` // org that owns this field RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0 'repo_id'"` // 0 = org-level (inherited by all repos) Scope CustomFieldScope `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'scope'"` Name string `xorm:"NOT NULL"` FieldType CustomFieldType `xorm:"VARCHAR(20) NOT NULL 'field_type'"` Description string `xorm:"TEXT"` Options string `xorm:"TEXT"` // JSON array for dropdown options Required bool `xorm:"NOT NULL DEFAULT false"` SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"` UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"` } func (CustomFieldDef) TableName() string { return "custom_field_def" } // CustomFieldValue stores a custom field value for an entity (issue or repo). type CustomFieldValue struct { ID int64 `xorm:"pk autoincr"` EntityID int64 `xorm:"INDEX NOT NULL 'entity_id'"` // issue ID or repo ID EntityType string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'issue' 'entity_type'"` // "issue" or "repo" FieldID int64 `xorm:"INDEX NOT NULL 'field_id'"` Value string `xorm:"TEXT"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"` UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"` } func (CustomFieldValue) TableName() string { return "custom_field_value" } // ────────────────────────────────────────────────────────────────────── // Queries for org-level field definitions // ────────────────────────────────────────────────────────────────────── // GetCustomFieldsByOwner returns all active field definitions for an org with a given scope. func GetCustomFieldsByOwner(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) { fields := make([]*CustomFieldDef, 0, 10) return fields, db.GetEngine(ctx). Where("owner_id = ? AND scope = ? AND is_active = ?", ownerID, scope, true). OrderBy("sort_order ASC, id ASC"). Find(&fields) } // GetAllCustomFieldsByOwner returns all field definitions for an org (including inactive). func GetAllCustomFieldsByOwner(ctx context.Context, ownerID int64) ([]*CustomFieldDef, error) { fields := make([]*CustomFieldDef, 0, 10) return fields, db.GetEngine(ctx). Where("owner_id = ?", ownerID). OrderBy("scope ASC, sort_order ASC, id ASC"). Find(&fields) } // GetCustomFieldsByOwnerAndScope returns all fields for an org filtered by scope. func GetCustomFieldsByOwnerAndScope(ctx context.Context, ownerID int64, scope CustomFieldScope) ([]*CustomFieldDef, error) { fields := make([]*CustomFieldDef, 0, 10) return fields, db.GetEngine(ctx). Where("owner_id = ? AND scope = ?", ownerID, scope). OrderBy("sort_order ASC, id ASC"). Find(&fields) } // ────────────────────────────────────────────────────────────────────── // Backward-compatible queries (load by repo's owner) // ────────────────────────────────────────────────────────────────────── // GetCustomFieldsByRepo returns active issue-scoped fields for a repo's org. // This is the main query used by the issue sidebar. func GetCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) { // First try org-level fields (owner_id != 0, repo_id = 0) // Fall back to legacy repo-level fields (repo_id = repoID) fields := make([]*CustomFieldDef, 0, 10) return fields, db.GetEngine(ctx). Where("((owner_id != 0 AND repo_id = 0) OR repo_id = ?) AND scope = ? AND is_active = ?", repoID, CustomFieldScopeIssue, true). OrderBy("sort_order ASC, id ASC"). Find(&fields) } // GetAllCustomFieldsByRepo returns all field definitions for a repo (for settings page). func GetAllCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) { fields := make([]*CustomFieldDef, 0, 10) return fields, db.GetEngine(ctx). Where("repo_id = ?", repoID). OrderBy("sort_order ASC, id ASC"). Find(&fields) } // ────────────────────────────────────────────────────────────────────── // Field definition CRUD // ────────────────────────────────────────────────────────────────────── // GetCustomFieldDefByID returns a single field definition. func GetCustomFieldDefByID(ctx context.Context, id int64) (*CustomFieldDef, error) { field := new(CustomFieldDef) has, err := db.GetEngine(ctx).ID(id).Get(field) if err != nil { return nil, err } if !has { return nil, db.ErrNotExist{Resource: "CustomFieldDef", ID: id} } return field, nil } // CreateCustomFieldDef creates a new custom field definition. func CreateCustomFieldDef(ctx context.Context, field *CustomFieldDef) error { _, err := db.GetEngine(ctx).Insert(field) return err } // UpdateCustomFieldDef updates a custom field definition. func UpdateCustomFieldDef(ctx context.Context, field *CustomFieldDef) error { _, err := db.GetEngine(ctx).ID(field.ID).AllCols().Update(field) return err } // DeleteCustomFieldDef deletes a field definition and all its values. func DeleteCustomFieldDef(ctx context.Context, id int64) error { if _, err := db.GetEngine(ctx).Where("field_id = ?", id).Delete(new(CustomFieldValue)); err != nil { return err } _, err := db.GetEngine(ctx).ID(id).Delete(new(CustomFieldDef)) return err } // ────────────────────────────────────────────────────────────────────── // Field values — generic entity-based (works for issues and repos) // ────────────────────────────────────────────────────────────────────── // GetCustomFieldValuesMap returns field_id -> value for an entity. func GetCustomFieldValuesMap(ctx context.Context, entityID int64) (map[int64]string, error) { values := make([]*CustomFieldValue, 0, 10) if err := db.GetEngine(ctx).Where("entity_id = ?", entityID).Find(&values); err != nil { return nil, err } result := make(map[int64]string, len(values)) for _, v := range values { result[v.FieldID] = v.Value } return result, nil } // SetCustomFieldValue creates or updates a single custom field value. func SetCustomFieldValue(ctx context.Context, entityID, fieldID int64, value string) error { existing := new(CustomFieldValue) has, err := db.GetEngine(ctx).Where("entity_id = ? AND field_id = ?", entityID, fieldID).Get(existing) if err != nil { return err } if has { existing.Value = value _, err = db.GetEngine(ctx).ID(existing.ID).Cols("value").Update(existing) return err } _, err = db.GetEngine(ctx).Insert(&CustomFieldValue{ EntityID: entityID, FieldID: fieldID, Value: value, }) return err } // SetCustomFieldValues sets multiple custom field values for an entity. func SetCustomFieldValues(ctx context.Context, entityID int64, values map[int64]string) error { for fieldID, value := range values { if err := SetCustomFieldValue(ctx, entityID, fieldID, value); err != nil { return err } } return nil }