feat(issues): custom fields foundation #447
@@ -11,7 +11,7 @@
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
@@ -24,13 +24,12 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
@@ -40,10 +39,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
@@ -138,101 +133,6 @@ jobs:
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
@@ -256,14 +156,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -370,14 +270,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -704,7 +604,7 @@ jobs:
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
@@ -773,11 +673,10 @@ jobs:
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, release_config, scripts_governance, repo_health]
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.release_config.result == 'failure' ||
|
||||
needs.scripts_governance.result == 'failure' ||
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
@@ -803,10 +702,6 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Release Configuration" \
|
||||
"${{ needs.release_config.result }}" \
|
||||
"Required repository variables are missing (RS_FTP_PATH_SUFFIX). Check repository settings."
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
@@ -814,4 +709,3 @@ jobs:
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright 2026 Moko Consulting. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package issues
|
||||
|
||||
@@ -10,86 +10,124 @@ import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// CustomFieldDefinition defines a custom field for a repository's issues
|
||||
type CustomFieldDefinition struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL" json:"repo_id"`
|
||||
Name string `xorm:"NOT NULL" json:"name"`
|
||||
FieldType string `xorm:"NOT NULL" json:"field_type"` // text, number, date, dropdown, checkbox
|
||||
Description string `json:"description"`
|
||||
Required bool `xorm:"NOT NULL DEFAULT false" json:"required"`
|
||||
Position int `xorm:"NOT NULL DEFAULT 0" json:"position"`
|
||||
Options string `xorm:"TEXT" json:"options"` // JSON array for dropdown options
|
||||
DefaultVal string `json:"default_value"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created" json:"created_at"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated" json:"updated_at"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CustomFieldDefinition))
|
||||
db.RegisterModel(new(CustomFieldDef))
|
||||
db.RegisterModel(new(CustomFieldValue))
|
||||
}
|
||||
|
||||
// CustomFieldValue stores the value of a custom field for a specific issue
|
||||
// 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"
|
||||
)
|
||||
|
||||
// CustomFieldDef defines a custom field available for issues in a repository.
|
||||
type CustomFieldDef struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
|
||||
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 a specific issue.
|
||||
type CustomFieldValue struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL" json:"issue_id"`
|
||||
FieldID int64 `xorm:"INDEX NOT NULL" json:"field_id"`
|
||||
Value string `xorm:"TEXT" json:"value"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created" json:"created_at"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated" json:"updated_at"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL 'issue_id'"`
|
||||
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'"`
|
||||
}
|
||||
|
||||
// GetCustomFieldsByRepoID returns all custom field definitions for a repo
|
||||
func GetCustomFieldsByRepoID(ctx context.Context, repoID int64) ([]*CustomFieldDefinition, error) {
|
||||
fields := make([]*CustomFieldDefinition, 0)
|
||||
return fields, db.GetEngine(ctx).Where("repo_id = ?", repoID).OrderBy("position ASC").Find(&fields)
|
||||
func (CustomFieldValue) TableName() string {
|
||||
return "custom_field_value"
|
||||
}
|
||||
|
||||
// GetCustomFieldByID returns a custom field definition by ID
|
||||
func GetCustomFieldByID(ctx context.Context, id int64) (*CustomFieldDefinition, error) {
|
||||
field := &CustomFieldDefinition{ID: id}
|
||||
has, err := db.GetEngine(ctx).Get(field)
|
||||
// GetCustomFieldsByRepo returns all active custom field definitions for a repo.
|
||||
func GetCustomFieldsByRepo(ctx context.Context, repoID int64) ([]*CustomFieldDef, error) {
|
||||
fields := make([]*CustomFieldDef, 0, 10)
|
||||
return fields, db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND is_active = ?", repoID, true).
|
||||
OrderBy("sort_order ASC, id ASC").
|
||||
Find(&fields)
|
||||
}
|
||||
|
||||
// GetAllCustomFieldsByRepo returns all custom field definitions including inactive.
|
||||
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)
|
||||
}
|
||||
|
||||
// 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, nil
|
||||
return nil, db.ErrNotExist{Resource: "CustomFieldDef", ID: id}
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// CreateCustomField creates a new custom field definition
|
||||
func CreateCustomField(ctx context.Context, field *CustomFieldDefinition) error {
|
||||
// CreateCustomFieldDef creates a new custom field definition.
|
||||
func CreateCustomFieldDef(ctx context.Context, field *CustomFieldDef) error {
|
||||
_, err := db.GetEngine(ctx).Insert(field)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCustomField updates a custom field definition
|
||||
func UpdateCustomField(ctx context.Context, field *CustomFieldDefinition) error {
|
||||
// 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
|
||||
}
|
||||
|
||||
// DeleteCustomField deletes a custom field and all its values
|
||||
func DeleteCustomField(ctx context.Context, id int64) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
if _, err := sess.Where("field_id = ?", id).Delete(&CustomFieldValue{}); err != nil {
|
||||
// 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 := sess.ID(id).Delete(&CustomFieldDefinition{})
|
||||
_, err := db.GetEngine(ctx).ID(id).Delete(new(CustomFieldDef))
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomFieldValues returns all custom field values for an issue
|
||||
func GetCustomFieldValues(ctx context.Context, issueID int64) ([]*CustomFieldValue, error) {
|
||||
values := make([]*CustomFieldValue, 0)
|
||||
return values, db.GetEngine(ctx).Where("issue_id = ?", issueID).Find(&values)
|
||||
// GetCustomFieldValuesMap returns field_id -> value for an issue.
|
||||
func GetCustomFieldValuesMap(ctx context.Context, issueID int64) (map[int64]string, error) {
|
||||
values := make([]*CustomFieldValue, 0, 10)
|
||||
if err := db.GetEngine(ctx).Where("issue_id = ?", issueID).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 sets or updates a custom field value for an issue
|
||||
// SetCustomFieldValue creates or updates a single custom field value.
|
||||
func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value string) error {
|
||||
existing := &CustomFieldValue{}
|
||||
existing := new(CustomFieldValue)
|
||||
has, err := db.GetEngine(ctx).Where("issue_id = ? AND field_id = ?", issueID, fieldID).Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -107,8 +145,12 @@ func SetCustomFieldValue(ctx context.Context, issueID, fieldID int64, value stri
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCustomFieldValue deletes a specific custom field value
|
||||
func DeleteCustomFieldValue(ctx context.Context, issueID, fieldID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("issue_id = ? AND field_id = ?", issueID, fieldID).Delete(&CustomFieldValue{})
|
||||
return err
|
||||
// SetCustomFieldValues sets multiple custom field values for an issue.
|
||||
func SetCustomFieldValues(ctx context.Context, issueID int64, values map[int64]string) error {
|
||||
for fieldID, value := range values {
|
||||
if err := SetCustomFieldValue(ctx, issueID, fieldID, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -420,6 +420,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(340, "Sync license system columns (key_raw, payment_ref, heartbeat, archive, metadata)", v1_27.SyncLicenseSystemColumns),
|
||||
newMigration(341, "Add parent_org_id to user table for enterprise sub-org hierarchy", v1_27.AddParentOrgIDToUser),
|
||||
newMigration(342, "Add is_hidden to repository for three-level visibility", v1_27.AddIsHiddenToRepository),
|
||||
newMigration(343, "Add custom field tables for issue custom fields", v1_27.AddCustomFieldTables),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type customFieldDef343 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
|
||||
Name string `xorm:"NOT NULL"`
|
||||
FieldType string `xorm:"VARCHAR(20) NOT NULL 'field_type'"`
|
||||
Description string `xorm:"TEXT"`
|
||||
Options string `xorm:"TEXT"`
|
||||
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 (customFieldDef343) TableName() string {
|
||||
return "custom_field_def"
|
||||
}
|
||||
|
||||
type customFieldValue343 struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX NOT NULL 'issue_id'"`
|
||||
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 (customFieldValue343) TableName() string {
|
||||
return "custom_field_value"
|
||||
}
|
||||
|
||||
// AddCustomFieldTables creates the custom_field_def and custom_field_value tables.
|
||||
func AddCustomFieldTables(x *xorm.Engine) error {
|
||||
return x.Sync(new(customFieldDef343), new(customFieldValue343))
|
||||
}
|
||||
@@ -2716,6 +2716,22 @@
|
||||
"repo.settings.download_gating": "Download Gating",
|
||||
"repo.settings.support_url": "Support / Product Page URL",
|
||||
"repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.",
|
||||
"repo.settings.custom_fields": "Custom Fields",
|
||||
"repo.settings.custom_field_new": "New Field",
|
||||
"repo.settings.custom_field_create": "Create Field",
|
||||
"repo.settings.custom_field_name": "Field Name",
|
||||
"repo.settings.custom_field_type": "Type",
|
||||
"repo.settings.custom_field_description": "Description",
|
||||
"repo.settings.custom_field_options": "Options (JSON)",
|
||||
"repo.settings.custom_field_options_help": "JSON array for dropdown fields. e.g. [\"Low\",\"Medium\",\"High\"]",
|
||||
"repo.settings.custom_field_required": "Required",
|
||||
"repo.settings.custom_field_sort_order": "Sort Order",
|
||||
"repo.settings.custom_field_created": "Custom field created.",
|
||||
"repo.settings.custom_field_updated": "Custom field updated.",
|
||||
"repo.settings.custom_field_deleted": "Custom field deleted.",
|
||||
"repo.settings.custom_field_confirm_delete": "Delete this custom field? All values stored for this field will be lost.",
|
||||
"repo.settings.custom_fields_none": "No Custom Fields",
|
||||
"repo.settings.custom_fields_none_desc": "Define custom fields to add structured metadata to issues.",
|
||||
"repo.settings.features": "Features",
|
||||
"repo.settings.features_units": "Units",
|
||||
"repo.settings.change_visibility": "Change Visibility",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
const tplSettingsCustomFields templates.TplName = "repo/settings/custom_fields"
|
||||
|
||||
// CustomFields displays the custom fields settings page.
|
||||
func CustomFields(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("repo.settings.custom_fields")
|
||||
ctx.Data["PageIsSettingsCustomFields"] = true
|
||||
|
||||
fields, err := issues_model.GetAllCustomFieldsByRepo(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetAllCustomFieldsByRepo", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["CustomFields"] = fields
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsCustomFields)
|
||||
}
|
||||
|
||||
// CustomFieldsCreatePost creates a new custom field definition.
|
||||
func CustomFieldsCreatePost(ctx *context.Context) {
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
|
||||
field := &issues_model.CustomFieldDef{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
Name: ctx.FormString("name"),
|
||||
FieldType: issues_model.CustomFieldType(ctx.FormString("field_type")),
|
||||
Description: ctx.FormString("description"),
|
||||
Options: ctx.FormString("options"),
|
||||
Required: ctx.FormString("required") == "on",
|
||||
SortOrder: sortOrder,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if field.Name == "" {
|
||||
ctx.Flash.Error("Field name is required")
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("CreateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_created"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// CustomFieldsEditPost updates a custom field definition.
|
||||
func CustomFieldsEditPost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
field.Name = ctx.FormString("name")
|
||||
field.FieldType = issues_model.CustomFieldType(ctx.FormString("field_type"))
|
||||
field.Description = ctx.FormString("description")
|
||||
field.Options = ctx.FormString("options")
|
||||
field.Required = ctx.FormString("required") == "on"
|
||||
field.IsActive = ctx.FormString("is_active") == "on"
|
||||
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
|
||||
field.SortOrder = sortOrder
|
||||
|
||||
if err := issues_model.UpdateCustomFieldDef(ctx, field); err != nil {
|
||||
ctx.ServerError("UpdateCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_updated"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
|
||||
// CustomFieldsDeletePost deletes a custom field definition.
|
||||
func CustomFieldsDeletePost(ctx *context.Context) {
|
||||
id := ctx.PathParamInt64("id")
|
||||
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetCustomFieldDefByID", err)
|
||||
return
|
||||
}
|
||||
if field.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
|
||||
ctx.ServerError("DeleteCustomFieldDef", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.settings.custom_field_deleted"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/custom-fields")
|
||||
}
|
||||
@@ -1187,6 +1187,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Combo("/advanced").Get(repo_setting.AdvancedSettings).Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
|
||||
}, repo_setting.SettingsCtxData)
|
||||
m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost)
|
||||
m.Group("/custom-fields", func() {
|
||||
m.Get("", repo_setting.CustomFields)
|
||||
m.Post("", repo_setting.CustomFieldsCreatePost)
|
||||
m.Post("/{id}/edit", repo_setting.CustomFieldsEditPost)
|
||||
m.Post("/{id}/delete", repo_setting.CustomFieldsDeletePost)
|
||||
})
|
||||
|
||||
m.Group("/collaboration", func() {
|
||||
m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings custom-fields")}}
|
||||
<div class="user-main-content twelve wide column">
|
||||
<h4 class="ui top attached header tw-flex tw-items-center tw-justify-between">
|
||||
<span>{{svg "octicon-list-unordered" 16}} {{ctx.Locale.Tr "repo.settings.custom_fields"}}</span>
|
||||
<button class="ui primary small button show-modal" data-modal="#custom-field-create-modal">
|
||||
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.settings.custom_field_new"}}
|
||||
</button>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .CustomFields}}
|
||||
<table class="ui compact table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repo.settings.custom_field_name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.settings.custom_field_type"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.settings.custom_field_required"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .CustomFields}}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
|
||||
</td>
|
||||
<td><code>{{.FieldType}}</code></td>
|
||||
<td>{{if .Required}}{{svg "octicon-check" 14}}{{end}}</td>
|
||||
<td>
|
||||
{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>
|
||||
{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}
|
||||
</td>
|
||||
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
|
||||
<button class="ui tiny button show-modal"
|
||||
data-modal="#custom-field-edit-modal"
|
||||
data-modal-custom-field-edit-modal-field-id="{{.ID}}"
|
||||
data-modal-custom-field-edit-modal-field-name="{{.Name}}"
|
||||
data-modal-custom-field-edit-modal-field-type="{{.FieldType}}"
|
||||
data-modal-custom-field-edit-modal-field-desc="{{.Description}}"
|
||||
data-modal-custom-field-edit-modal-field-options="{{.Options}}"
|
||||
data-modal-custom-field-edit-modal-field-required="{{.Required}}"
|
||||
data-modal-custom-field-edit-modal-field-active="{{.IsActive}}"
|
||||
data-modal-custom-field-edit-modal-field-order="{{.SortOrder}}"
|
||||
title="{{ctx.Locale.Tr "edit"}}">
|
||||
{{svg "octicon-pencil" 14}}
|
||||
</button>
|
||||
<button class="ui tiny red button link-action"
|
||||
data-url="{{$.RepoLink}}/settings/custom-fields/{{.ID}}/delete"
|
||||
data-modal-confirm="{{ctx.Locale.Tr "repo.settings.custom_field_confirm_delete"}}"
|
||||
title="{{ctx.Locale.Tr "delete"}}">
|
||||
{{svg "octicon-trash" 14}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-list-unordered" 48}}
|
||||
<h2>{{ctx.Locale.Tr "repo.settings.custom_fields_none"}}</h2>
|
||||
<p>{{ctx.Locale.Tr "repo.settings.custom_fields_none_desc"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Create Custom Field Modal ── */}}
|
||||
<div class="ui small modal" id="custom-field-create-modal">
|
||||
<div class="header">{{svg "octicon-plus"}} {{ctx.Locale.Tr "repo.settings.custom_field_new"}}</div>
|
||||
<div class="content">
|
||||
<form class="ui form" method="post" action="{{.RepoLink}}/settings/custom-fields">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<div class="required field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_name"}}</label>
|
||||
<input name="name" required placeholder="e.g. Priority, Sprint, Client Name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_type"}}</label>
|
||||
<select name="field_type" class="ui dropdown">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="dropdown">Dropdown</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="url">URL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_description"}}</label>
|
||||
<input name="description" placeholder="Help text shown below the field">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_options"}}</label>
|
||||
<input name="options" placeholder='["Option A","Option B","Option C"]'>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.custom_field_options_help"}}</p>
|
||||
</div>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input name="required" type="checkbox">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_required"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.custom_field_sort_order"}}</label>
|
||||
<input name="sort_order" type="number" value="0" min="0">
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.settings.custom_field_create"))}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -12,6 +12,9 @@
|
||||
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsSettingsCustomFields}}active {{end}}item" href="{{.RepoLink}}/settings/custom-fields">
|
||||
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.custom_fields"}}
|
||||
</a>
|
||||
{{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}}
|
||||
<a class="{{if .PageIsSettingsPublicAccess}}active {{end}}item" href="{{.RepoLink}}/settings/public_access">
|
||||
{{svg "octicon-eye"}} {{ctx.Locale.Tr "repo.settings.public_access"}}
|
||||
|
||||
Reference in New Issue
Block a user