Files
Jonathan Miller 6bd9548b2a
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Failing after 23s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 1m4s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
feat(custom-fields): move to org-level definitions with issue and repo scopes
- CustomFieldDef now has owner_id (org) and scope (issue/repo)
- Issue sidebar loads fields by org owner_id, not repo_id
- Org Settings > Custom Fields page for managing field definitions
- Repo Settings > Metadata page for filling in repo-scoped values
- Migration v345 adds owner_id, scope, entity_id, entity_type columns
- Per-repo custom field management replaced by org-level
- Replaces .mokogitea/manifest.xml with database-backed metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 19:11:22 -05:00

217 lines
9.3 KiB
Go

// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}