feat(api): custom fields API endpoints #490
+15
-3
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user