feat(api): custom fields API endpoints #490

Merged
jmiller merged 1 commits from dev into main 2026-06-05 00:21:00 +00:00
3 changed files with 291 additions and 3 deletions
+15 -3
View File
@@ -1655,9 +1655,16 @@ func Routes() *web.Router {
// })
// })
// })
// TODO: custom-fields API routes - handler not yet implemented
// m.Group("/custom-fields", func() { ... })
// m.Group("/issues/{index}/custom-fields", func() { ... })
// Repo metadata (repo-scoped custom fields)
m.Group("/metadata", func() {
m.Get("", repo.GetRepoMetadata)
m.Put("", reqToken(), reqRepoWriter(unit.TypeCode), repo.SetRepoMetadata)
})
// Issue custom fields
m.Group("/issues/{index}/custom-fields", func() {
m.Get("", repo.GetIssueCustomFields)
m.Put("", reqToken(), reqRepoWriter(unit.TypeIssues), repo.SetIssueCustomFields)
})
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
@@ -1758,6 +1765,11 @@ func Routes() *web.Router {
m.Delete("", org.UnblockUser)
})
}, reqToken(), reqOrgOwnership())
m.Group("/custom-fields", func() {
m.Get("", org.ListOrgCustomFields)
m.Post("", reqToken(), reqOrgOwnership(), org.CreateOrgCustomField)
m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField)
})
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
m.Group("/teams/{teamid}", func() {
m.Combo("").Get(reqToken(), org.GetTeam).
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package org
import (
"encoding/json"
"net/http"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
type apiCustomFieldDef struct {
ID int64 `json:"id"`
OwnerID int64 `json:"owner_id"`
Scope string `json:"scope"`
Name string `json:"name"`
FieldType string `json:"field_type"`
Description string `json:"description"`
Options any `json:"options"`
Required bool `json:"required"`
SortOrder int `json:"sort_order"`
IsActive bool `json:"is_active"`
}
func toAPIFieldDef(f *issues_model.CustomFieldDef) apiCustomFieldDef {
var opts any
if f.Options != "" {
var parsed []string
if json.Unmarshal([]byte(f.Options), &parsed) == nil {
opts = parsed
} else {
opts = f.Options
}
}
return apiCustomFieldDef{
ID: f.ID,
OwnerID: f.OwnerID,
Scope: string(f.Scope),
Name: f.Name,
FieldType: string(f.FieldType),
Description: f.Description,
Options: opts,
Required: f.Required,
SortOrder: f.SortOrder,
IsActive: f.IsActive,
}
}
// ListOrgCustomFields returns all custom field definitions for an org.
func ListOrgCustomFields(ctx *context.APIContext) {
fields, err := issues_model.GetAllCustomFieldsByOwner(ctx, ctx.Org.Organization.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
result := make([]apiCustomFieldDef, 0, len(fields))
for _, f := range fields {
result = append(result, toAPIFieldDef(f))
}
ctx.JSON(http.StatusOK, result)
}
// CreateOrgCustomField creates a new custom field definition.
func CreateOrgCustomField(ctx *context.APIContext) {
var req struct {
Scope string `json:"scope" binding:"Required"`
Name string `json:"name" binding:"Required"`
FieldType string `json:"field_type" binding:"Required"`
Description string `json:"description"`
Options []string `json:"options"`
Required bool `json:"required"`
SortOrder int `json:"sort_order"`
}
if err := ctx.Req.ParseForm(); err != nil {
ctx.APIError(http.StatusBadRequest, err)
return
}
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, err)
return
}
if req.Name == "" || req.Scope == "" {
ctx.APIError(http.StatusBadRequest, "name and scope are required")
return
}
scope := issues_model.CustomFieldScope(req.Scope)
if scope != issues_model.CustomFieldScopeIssue && scope != issues_model.CustomFieldScopeRepo {
ctx.APIError(http.StatusBadRequest, "scope must be 'issue' or 'repo'")
return
}
var optionsJSON string
if len(req.Options) > 0 {
data, _ := json.Marshal(req.Options)
optionsJSON = string(data)
}
field := &issues_model.CustomFieldDef{
OwnerID: ctx.Org.Organization.ID,
RepoID: 0,
Scope: scope,
Name: req.Name,
FieldType: issues_model.CustomFieldType(req.FieldType),
Description: req.Description,
Options: optionsJSON,
Required: req.Required,
SortOrder: req.SortOrder,
IsActive: true,
}
if err := issues_model.CreateCustomFieldDef(ctx, field); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, toAPIFieldDef(field))
}
// DeleteOrgCustomField deletes a custom field definition.
func DeleteOrgCustomField(ctx *context.APIContext) {
id := ctx.PathParamInt64("id")
field, err := issues_model.GetCustomFieldDefByID(ctx, id)
if err != nil {
ctx.APIErrorNotFound()
return
}
if field.OwnerID != ctx.Org.Organization.ID {
ctx.APIErrorNotFound()
return
}
if err := issues_model.DeleteCustomFieldDef(ctx, id); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"encoding/json"
"net/http"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// GetRepoMetadata returns all repo-scoped custom field values.
func GetRepoMetadata(ctx *context.APIContext) {
ownerID := ctx.Repo.Repository.OwnerID
repoID := ctx.Repo.Repository.ID
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
if err != nil {
ctx.APIErrorInternal(err)
return
}
values, err := issues_model.GetCustomFieldValuesMap(ctx, repoID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
result := make(map[string]string, len(fields))
for _, f := range fields {
result[f.Name] = values[f.ID]
}
ctx.JSON(http.StatusOK, result)
}
// SetRepoMetadata sets repo-scoped custom field values.
func SetRepoMetadata(ctx *context.APIContext) {
ownerID := ctx.Repo.Repository.OwnerID
repoID := ctx.Repo.Repository.ID
var req map[string]string
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, err)
return
}
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeRepo)
if err != nil {
ctx.APIErrorInternal(err)
return
}
// Build name->ID map
nameToID := make(map[string]int64, len(fields))
for _, f := range fields {
nameToID[f.Name] = f.ID
}
for name, value := range req {
if fieldID, ok := nameToID[name]; ok {
if err := issues_model.SetCustomFieldValue(ctx, repoID, fieldID, value); err != nil {
ctx.APIErrorInternal(err)
return
}
}
}
ctx.Status(http.StatusNoContent)
}
// GetIssueCustomFields returns custom field values for an issue.
func GetIssueCustomFields(ctx *context.APIContext) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
ctx.APIErrorNotFound()
return
}
ownerID := ctx.Repo.Repository.OwnerID
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
if err != nil {
ctx.APIErrorInternal(err)
return
}
values, err := issues_model.GetCustomFieldValuesMap(ctx, issue.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
result := make(map[string]string, len(fields))
for _, f := range fields {
result[f.Name] = values[f.ID]
}
ctx.JSON(http.StatusOK, result)
}
// SetIssueCustomFields sets custom field values for an issue.
func SetIssueCustomFields(ctx *context.APIContext) {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
if err != nil {
ctx.APIErrorNotFound()
return
}
var req map[string]string
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, err)
return
}
ownerID := ctx.Repo.Repository.OwnerID
fields, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
if err != nil {
ctx.APIErrorInternal(err)
return
}
nameToID := make(map[string]int64, len(fields))
for _, f := range fields {
nameToID[f.Name] = f.ID
}
for name, value := range req {
if fieldID, ok := nameToID[name]; ok {
if err := issues_model.SetCustomFieldValue(ctx, issue.ID, fieldID, value); err != nil {
ctx.APIErrorInternal(err)
return
}
}
}
ctx.Status(http.StatusNoContent)
}