From 539619be2ff3f77887f7046bfbb4f863b6b7cffe Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 4 Jun 2026 19:20:21 -0500 Subject: [PATCH] feat(api): custom fields API for org definitions, repo metadata, and issue values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API endpoints: - GET/POST/DELETE /api/v1/orgs/{org}/custom-fields — org field definitions - GET/PUT /api/v1/repos/{owner}/{repo}/metadata — repo-scoped field values - GET/PUT /api/v1/repos/{owner}/{repo}/issues/{index}/custom-fields — issue values All endpoints use field names as keys (not IDs) for ergonomic access. Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/api/v1/api.go | 18 +++- routers/api/v1/org/custom_fields.go | 139 +++++++++++++++++++++++++++ routers/api/v1/repo/custom_fields.go | 137 ++++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 routers/api/v1/org/custom_fields.go create mode 100644 routers/api/v1/repo/custom_fields.go diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index cef9e8cd44..2df3e5af6e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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). diff --git a/routers/api/v1/org/custom_fields.go b/routers/api/v1/org/custom_fields.go new file mode 100644 index 0000000000..b15997836e --- /dev/null +++ b/routers/api/v1/org/custom_fields.go @@ -0,0 +1,139 @@ +// Copyright 2026 Moko Consulting +// 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) +} diff --git a/routers/api/v1/repo/custom_fields.go b/routers/api/v1/repo/custom_fields.go new file mode 100644 index 0000000000..eb7d139cb4 --- /dev/null +++ b/routers/api/v1/repo/custom_fields.go @@ -0,0 +1,137 @@ +// Copyright 2026 Moko Consulting +// 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) +} -- 2.52.0