From 9ebe1b26b14f83f46399108560eeabf5349496a8 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 4 Jun 2026 21:47:15 -0500 Subject: [PATCH 1/5] feat(custom-fields): pre-fill custom fields from issue template YAML frontmatter Add `custom_fields` map to IssueTemplate struct so templates can specify default values (e.g. `Priority: Medium`). On new issue form, org-level issue-scoped fields appear in the sidebar with template defaults pre-selected. NewIssuePost saves the values after issue creation. The API create issue endpoint also accepts `custom_fields` by name. Closes #493 Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/structs/issue.go | 21 +++++----- routers/api/v1/repo/issue.go | 19 +++++++++ routers/web/repo/compare.go | 2 +- routers/web/repo/issue_new.go | 62 +++++++++++++++++++++++++++--- routers/web/repo/issue_view.go | 4 +- templates/repo/issue/new_form.tmpl | 25 ++++++++++++ 6 files changed, 116 insertions(+), 17 deletions(-) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index f108cf3d0a..516ed6235d 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -104,6 +104,8 @@ type CreateIssueOption struct { // list of project ids Projects []int64 `json:"projects"` Closed bool `json:"closed"` + // custom field values keyed by field name + CustomFields map[string]string `json:"custom_fields,omitempty"` } // EditIssueOption options for editing an issue @@ -190,15 +192,16 @@ const ( // IssueTemplate represents an issue template for a repository // swagger:model type IssueTemplate struct { - Name string `json:"name" yaml:"name"` - Title string `json:"title" yaml:"title"` - About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible - Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"` - Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"` - Ref string `json:"ref" yaml:"ref"` - Content string `json:"content" yaml:"-"` - Fields []*IssueFormField `json:"body" yaml:"body"` - FileName string `json:"file_name" yaml:"-"` + Name string `json:"name" yaml:"name"` + Title string `json:"title" yaml:"title"` + About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible + Labels IssueTemplateStringSlice `json:"labels" yaml:"labels"` + Assignees IssueTemplateStringSlice `json:"assignees" yaml:"assignees"` + Ref string `json:"ref" yaml:"ref"` + Content string `json:"content" yaml:"-"` + Fields []*IssueFormField `json:"body" yaml:"body"` + FileName string `json:"file_name" yaml:"-"` + CustomFields map[string]string `json:"custom_fields,omitempty" yaml:"custom_fields"` } type IssueTemplateStringSlice []string diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 6c85b2e4c2..dd11be0405 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -702,6 +702,25 @@ func CreateIssue(ctx *context.APIContext) { return } + // Save custom field values if provided (resolve field names to IDs). + if len(form.CustomFields) > 0 { + defs, defErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) + if defErr == nil && len(defs) > 0 { + vals := make(map[int64]string) + for _, def := range defs { + if v, ok := form.CustomFields[def.Name]; ok { + vals[def.ID] = v + } + } + if len(vals) > 0 { + if setErr := issues_model.SetCustomFieldValues(ctx, issue.ID, vals); setErr != nil { + ctx.APIErrorInternal(setErr) + return + } + } + } + } + if form.Closed { if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil { if issues_model.IsErrDependenciesLeft(err) { diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index c123843341..f2f5bc1290 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -630,7 +630,7 @@ func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Contex if ctx.Written() { return } - _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData) + _, templateErrs, _ := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData) if len(templateErrs) > 0 { ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) } diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 8fc5afa035..a3f359dfeb 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -4,6 +4,7 @@ package repo import ( + "encoding/json" "errors" "fmt" "html/template" @@ -36,10 +37,11 @@ import ( ) // Tries to load and set an issue template. The first return value indicates if a template was loaded. -func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error) { +// The third return value contains the template's custom_fields map (field name → default value). +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error, map[string]string) { commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) if err != nil { - return false, nil + return false, nil, nil } templateCandidates := make([]string, 0, 1+len(possibleFiles)) @@ -84,9 +86,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles } metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",") - return true, templateErrs + return true, templateErrs, template.CustomFields } - return false, templateErrs + return false, templateErrs, nil } // NewIssue render creating issue page @@ -128,7 +130,7 @@ func NewIssue(ctx *context.Context) { ctx.Data["Tags"] = tags ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) - templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData) + templateLoaded, errs, templateCustomFields := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData) maps.Copy(ret.TemplateErrors, errs) if ctx.Written() { return @@ -140,6 +142,32 @@ func NewIssue(ctx *context.Context) { ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues) + // Load org-level issue-scoped custom fields for the new issue sidebar. + customFieldDefs, _ := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) + ctx.Data["CustomFieldDefs"] = customFieldDefs + customFieldValues := make(map[int64]string) + fieldOptions := make(map[int64][]string) + if len(customFieldDefs) > 0 { + // Resolve template custom_fields (name → value) to field IDs. + if len(templateCustomFields) > 0 { + for _, def := range customFieldDefs { + if val, ok := templateCustomFields[def.Name]; ok { + customFieldValues[def.ID] = val + } + } + } + for _, f := range customFieldDefs { + if f.Options != "" { + var opts []string + if err := json.Unmarshal([]byte(f.Options), &opts); err == nil { + fieldOptions[f.ID] = opts + } + } + } + } + ctx.Data["CustomFieldValues"] = customFieldValues + ctx.Data["CustomFieldOptions"] = fieldOptions + if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded { // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters. ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther) @@ -377,6 +405,9 @@ func NewIssuePost(ctx *context.Context) { return } + // Save custom field values submitted from the new issue form. + saveCustomFieldsFromForm(ctx, repo.OwnerID, issue.ID) + log.Trace("Issue created: %d/%d", repo.ID, issue.ID) if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 { // When issue is in multiple projects, redirect to first project from form order. @@ -392,3 +423,24 @@ func NewIssuePost(ctx *context.Context) { } ctx.JSONRedirect(issue.Link()) } + +// saveCustomFieldsFromForm reads custom field values from the form +// (submitted as "custom-field-{fieldID}") and persists them for the issue. +func saveCustomFieldsFromForm(ctx *context.Context, ownerID, issueID int64) { + defs, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue) + if err != nil || len(defs) == 0 { + return + } + vals := make(map[int64]string) + for _, def := range defs { + v := ctx.Req.FormValue(fmt.Sprintf("custom-field-%d", def.ID)) + if v != "" { + vals[def.ID] = v + } + } + if len(vals) > 0 { + if err := issues_model.SetCustomFieldValues(ctx, issueID, vals); err != nil { + log.Error("saveCustomFieldsFromForm: %v", err) + } + } +} diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index dfa2a8b34e..672df411e7 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -339,8 +339,8 @@ func ViewIssue(ctx *context.Context) { ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects) ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled - // Load custom fields for the issue sidebar. - customFieldDefs, _ := issues_model.GetCustomFieldsByRepo(ctx, ctx.Repo.Repository.ID) + // Load custom fields for the issue sidebar (org-level issue-scoped fields). + customFieldDefs, _ := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) ctx.Data["CustomFieldDefs"] = customFieldDefs customFieldValues := make(map[int64]string) fieldOptions := make(map[int64][]string) diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index f6ee548e15..1388fa950e 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -59,6 +59,31 @@ {{end}} {{template "repo/issue/sidebar/assignee_list" $.IssuePageMetaData}} + {{if .CustomFieldDefs}} +
+
+ {{$values := .CustomFieldValues}} + {{$fieldOptions := .CustomFieldOptions}} + {{range .CustomFieldDefs}} + {{$currentVal := index $values .ID}} +
+ {{.Name}} + {{if ne .Options ""}} + {{$opts := index $fieldOptions .ID}} + + {{else}} + + {{end}} +
+ {{end}} +
+ {{end}} + {{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}}
-- 2.52.0 From 1935889f6b71bc1706a0bdcc07da26d60e849c8a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 4 Jun 2026 21:48:14 -0500 Subject: [PATCH 2/5] feat(updateserver): resolve extension metadata from custom fields with config fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add resolveExtensionMetadata() with cascading priority: org-level repo-scoped custom fields → update_stream_config table → repo-derived defaults. All six feed generators (Joomla, WordPress, Composer, Drupal, PrestaShop, WHMCS) now use this unified resolver. Repos can be migrated to custom fields gradually since the config table remains as fallback. Ref #492 Co-Authored-By: Claude Opus 4.6 (1M context) --- services/updateserver/composer.go | 13 +-- services/updateserver/drupal.go | 13 +-- services/updateserver/joomla.go | 137 ++++++++++++++++++++++------ services/updateserver/prestashop.go | 22 ++--- services/updateserver/whmcs.go | 11 +-- services/updateserver/wordpress.go | 28 +++--- 6 files changed, 136 insertions(+), 88 deletions(-) diff --git a/services/updateserver/composer.go b/services/updateserver/composer.go index 4353daa6d1..ad9326acfc 100644 --- a/services/updateserver/composer.go +++ b/services/updateserver/composer.go @@ -68,13 +68,15 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) + meta := resolveExtensionMetadata(ctx, repo, cfg) - // Composer package name: vendor/package + // Composer package name: vendor/package (override with custom field "Extension Name") packageName := fmt.Sprintf("%s/%s", strings.ToLower(repo.Owner.Name), strings.ToLower(repo.Name)) - if cfg != nil && cfg.ExtensionName != "" { - packageName = cfg.ExtensionName + if meta.Element != strings.ToLower(repo.Name) { + packageName = meta.Element } + description := meta.Description maintainer := repo.Owner.Name maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) if cfg != nil && cfg.Maintainer != "" { @@ -84,11 +86,6 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice maintainerURL = cfg.MaintainerURL } - description := "" - if cfg != nil && cfg.Description != "" { - description = cfg.Description - } - phpMin := "" if cfg != nil && cfg.PHPMinimum != "" { phpMin = ">=" + cfg.PHPMinimum diff --git a/services/updateserver/drupal.go b/services/updateserver/drupal.go index 1c9495021c..8e5472d4d7 100644 --- a/services/updateserver/drupal.go +++ b/services/updateserver/drupal.go @@ -66,16 +66,9 @@ func GenerateDrupalXML(ctx context.Context, repo *repo_model.Repository, allowed repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) - shortName := strings.ToLower(repo.Name) - title := repo.Name - if cfg != nil { - if cfg.ExtensionName != "" { - shortName = cfg.ExtensionName - } - if cfg.DisplayName != "" { - title = cfg.DisplayName - } - } + meta := resolveExtensionMetadata(ctx, repo, cfg) + shortName := meta.Element + title := meta.DisplayName streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 11139ddbc7..51319bc028 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -13,6 +13,7 @@ import ( "time" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage" @@ -160,9 +161,102 @@ func NormalizeChannel(ch string) string { } } +// extensionMetadata holds resolved metadata for feed generation. +// Fields are resolved with priority: custom field → config table → default. +type extensionMetadata struct { + Element string + DisplayName string + ExtType string + TargetVersion string + PHPMinimum string + Description string + SupportURL string +} + +// resolveExtensionMetadata loads extension metadata with cascading fallback: +// org-level repo-scoped custom fields → update_stream_config → repo-derived defaults. +func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, cfg *licenses.UpdateStreamConfig) extensionMetadata { + m := extensionMetadata{ + Element: strings.ToLower(repo.Name), + DisplayName: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name), + ExtType: "component", + TargetVersion: "(5|6)\\..*", + } + + // Apply config table values. + if cfg != nil { + if cfg.ExtensionName != "" { + m.Element = cfg.ExtensionName + } + if cfg.DisplayName != "" { + m.DisplayName = cfg.DisplayName + } + if cfg.ExtensionType != "" { + m.ExtType = cfg.ExtensionType + } + if cfg.TargetVersion != "" { + m.TargetVersion = cfg.TargetVersion + } + if cfg.PHPMinimum != "" { + m.PHPMinimum = cfg.PHPMinimum + } + if cfg.Description != "" { + m.Description = cfg.Description + } + if cfg.SupportURL != "" { + m.SupportURL = cfg.SupportURL + } + } + + // Override with custom field values (highest priority). + fields, err := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeRepo) + if err != nil || len(fields) == 0 { + return m + } + values, err := issues_model.GetCustomFieldValuesMap(ctx, repo.ID) + if err != nil || len(values) == 0 { + return m + } + + // Build name → value map from field definitions + values. + named := make(map[string]string, len(fields)) + for _, f := range fields { + if v, ok := values[f.ID]; ok && v != "" { + named[f.Name] = v + } + } + + if v := named["Extension Name"]; v != "" { + m.Element = v + } + if v := named["Display Name"]; v != "" { + m.DisplayName = v + } + if v := named["Extension Type"]; v != "" { + m.ExtType = v + } + if v := named["Target Version"]; v != "" { + m.TargetVersion = v + } + if v := named["PHP Minimum"]; v != "" { + m.PHPMinimum = v + } + if v := named["Support URL"]; v != "" { + m.SupportURL = v + } + if v := named["Download Gating"]; v != "" && cfg != nil { + cfg.DownloadGating = v + } + if v := named["Key Prefix"]; v != "" && cfg != nil { + cfg.KeyPrefix = v + } + + return m +} + // GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases. -// It returns the raw XML bytes. Extension metadata is read from the update stream config; -// falls back to repo name/owner when not configured. +// It returns the raw XML bytes. Extension metadata is resolved from custom fields first, +// then the update stream config, then repo-derived defaults. // allowedChannels optionally restricts output to specific channels (nil = all). func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey, stripDownloads bool, allowedChannels ...string) ([]byte, error) { releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ @@ -185,21 +279,18 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require } repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) - // Load extension metadata from config (falls back to repo-derived values). + // Load extension metadata with cascading fallback: + // custom fields → config table → repo-derived defaults. cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) + meta := resolveExtensionMetadata(ctx, repo, cfg) + + element := meta.Element + displayName := meta.DisplayName + extType := meta.ExtType + targetVersion := meta.TargetVersion + phpMinimum := meta.PHPMinimum + feedDescription := meta.Description - element := strings.ToLower(repo.Name) - if cfg != nil && cfg.ExtensionName != "" { - element = cfg.ExtensionName - } - displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name) - if cfg != nil && cfg.DisplayName != "" { - displayName = cfg.DisplayName - } - extType := "component" - if cfg != nil && cfg.ExtensionType != "" { - extType = cfg.ExtensionType - } // Maintainer and URL always come from the org profile. maintainer := repo.Owner.FullName if maintainer == "" { @@ -209,18 +300,6 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require if maintainerURL == "" { maintainerURL = fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) } - targetVersion := "(5|6)\\..*" - if cfg != nil && cfg.TargetVersion != "" { - targetVersion = cfg.TargetVersion - } - phpMinimum := "" - if cfg != nil && cfg.PHPMinimum != "" { - phpMinimum = cfg.PHPMinimum - } - feedDescription := "" - if cfg != nil && cfg.Description != "" { - feedDescription = cfg.Description - } // Resolve effective streams (repo override → org default → Joomla default). streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) @@ -319,8 +398,8 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require // Info URL: use support_url (product page), fall back to releases page. infoURL := fmt.Sprintf("%s/releases", repoLink) - if cfg != nil && cfg.SupportURL != "" { - infoURL = cfg.SupportURL + if meta.SupportURL != "" { + infoURL = meta.SupportURL } // Joomla element: packages use client_id=0 in #__extensions, diff --git a/services/updateserver/prestashop.go b/services/updateserver/prestashop.go index 81442f756e..8795500c71 100644 --- a/services/updateserver/prestashop.go +++ b/services/updateserver/prestashop.go @@ -55,23 +55,13 @@ func GeneratePrestaShopXML(ctx context.Context, repo *repo_model.Repository, all repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) - moduleName := strings.ToLower(repo.Name) - displayName := repo.Name + meta := resolveExtensionMetadata(ctx, repo, cfg) + moduleName := meta.Element + displayName := meta.DisplayName + description := meta.Description maintainer := repo.Owner.Name - description := "" - if cfg != nil { - if cfg.ExtensionName != "" { - moduleName = cfg.ExtensionName - } - if cfg.DisplayName != "" { - displayName = cfg.DisplayName - } - if cfg.Maintainer != "" { - maintainer = cfg.Maintainer - } - if cfg.Description != "" { - description = cfg.Description - } + if cfg != nil && cfg.Maintainer != "" { + maintainer = cfg.Maintainer } streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) diff --git a/services/updateserver/whmcs.go b/services/updateserver/whmcs.go index a3a95a50cb..da188162f9 100644 --- a/services/updateserver/whmcs.go +++ b/services/updateserver/whmcs.go @@ -50,23 +50,18 @@ func GenerateWHMCSJSON(ctx context.Context, repo *repo_model.Repository, license repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) - displayName := repo.Name + meta := resolveExtensionMetadata(ctx, repo, cfg) + displayName := meta.DisplayName + description := meta.Description maintainer := repo.Owner.Name maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) - description := "" if cfg != nil { - if cfg.DisplayName != "" { - displayName = cfg.DisplayName - } if cfg.Maintainer != "" { maintainer = cfg.Maintainer } if cfg.MaintainerURL != "" { maintainerURL = cfg.MaintainerURL } - if cfg.Description != "" { - description = cfg.Description - } } streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) diff --git a/services/updateserver/wordpress.go b/services/updateserver/wordpress.go index d17ef645d0..619ffa04ba 100644 --- a/services/updateserver/wordpress.go +++ b/services/updateserver/wordpress.go @@ -57,36 +57,30 @@ func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, lic baseURL := strings.TrimSuffix(setting.AppURL, "/") repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) - // Load extension metadata. + // Load extension metadata with cascading fallback: + // custom fields → config table → repo-derived defaults. cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) + meta := resolveExtensionMetadata(ctx, repo, cfg) - slug := strings.ToLower(repo.Name) - displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name) + slug := meta.Element + displayName := meta.DisplayName + requiresPHP := meta.PHPMinimum + homepage := repoLink + if meta.SupportURL != "" { + homepage = meta.SupportURL + } maintainer := repo.Owner.Name maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) - homepage := repoLink - requiresPHP := "" if cfg != nil { - if cfg.ExtensionName != "" { - slug = cfg.ExtensionName - } - if cfg.DisplayName != "" { - displayName = cfg.DisplayName - } if cfg.Maintainer != "" { maintainer = cfg.Maintainer } if cfg.MaintainerURL != "" { maintainerURL = cfg.MaintainerURL } - if cfg.SupportURL != "" { - homepage = cfg.SupportURL - } else if cfg.InfoURL != "" { + if homepage == repoLink && cfg.InfoURL != "" { homepage = cfg.InfoURL } - if cfg.PHPMinimum != "" { - requiresPHP = cfg.PHPMinimum - } } // Resolve streams and find the latest stable release. -- 2.52.0 From b72f88e78bf4fd79a1fc04da3493398d9300c1a7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 4 Jun 2026 21:53:04 -0500 Subject: [PATCH 3/5] docs(changelog): add #492 and #493 entries Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8881870b8e..3352f10677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,14 @@ All notable changes to MokoGitea are documented here. Versions follow the format * feat(settings): icons on all settings navbars (repo, org, user, admin) * feat(ui): styled 403 Access Denied page with inline login form * feat(issues): custom fields with inline editing in issue sidebar + * feat(issues): pre-fill custom fields from issue template YAML frontmatter (#493) + * Templates specify `custom_fields:` map (field name → default value) + * New issue sidebar shows org-level fields with template defaults pre-selected + * API create issue accepts `custom_fields` map by name + * feat(updateserver): resolve extension metadata from org-level custom fields (#492) + * Cascading fallback: custom fields → config table → repo-derived defaults + * All six generators updated (Joomla, WordPress, Composer, Drupal, PrestaShop, WHMCS) + * Repos can be migrated to custom fields gradually * feat(ui): two-in-one Update Server / Licenses tab * No gating: shows "Update Server" tab with feed URLs only * Gated: shows "Licenses" tab with full key management @@ -73,6 +81,7 @@ All notable changes to MokoGitea are documented here. Versions follow the format * fix(updateserver): prevent stream name tag from overriding asset-derived version * fix(build): restore build/ directory after accidental deletion * fix(licenses): master key banner removed, master keys sort first in table + * fix(issues): issue sidebar loads org-level fields instead of legacy repo-level fields ## [v1.26.1-moko.05] - 2026-05-31 -- 2.52.0 From cd4c701cb69cd2b4c103aa30ebfda7953cacb6c5 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 4 Jun 2026 21:59:50 -0500 Subject: [PATCH 4/5] fix(custom-fields): address code review findings - API: return 500 on GetCustomFieldsByOwner failure instead of silently swallowing the error - resolveExtensionMetadata: add DownloadGating/KeyPrefix to metadata struct instead of mutating the caller's cfg pointer (side effect) - resolveExtensionMetadata: add Description custom field mapping - Composer: use meta.PHPMinimum instead of bypassing the cascade - Web form: flash error on custom field save failure instead of silent log Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/api/v1/repo/issue.go | 6 +++++- routers/web/repo/issue_new.go | 1 + services/updateserver/composer.go | 4 ++-- services/updateserver/joomla.go | 33 ++++++++++++++++++++----------- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index dd11be0405..b6c242b105 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -705,7 +705,11 @@ func CreateIssue(ctx *context.APIContext) { // Save custom field values if provided (resolve field names to IDs). if len(form.CustomFields) > 0 { defs, defErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) - if defErr == nil && len(defs) > 0 { + if defErr != nil { + ctx.APIErrorInternal(defErr) + return + } + if len(defs) > 0 { vals := make(map[int64]string) for _, def := range defs { if v, ok := form.CustomFields[def.Name]; ok { diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index a3f359dfeb..d9993f8bf4 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -441,6 +441,7 @@ func saveCustomFieldsFromForm(ctx *context.Context, ownerID, issueID int64) { if len(vals) > 0 { if err := issues_model.SetCustomFieldValues(ctx, issueID, vals); err != nil { log.Error("saveCustomFieldsFromForm: %v", err) + ctx.Flash.Error("Failed to save custom field values") } } } diff --git a/services/updateserver/composer.go b/services/updateserver/composer.go index ad9326acfc..1833f2c4fc 100644 --- a/services/updateserver/composer.go +++ b/services/updateserver/composer.go @@ -87,8 +87,8 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice } phpMin := "" - if cfg != nil && cfg.PHPMinimum != "" { - phpMin = ">=" + cfg.PHPMinimum + if meta.PHPMinimum != "" { + phpMin = ">=" + meta.PHPMinimum } streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 51319bc028..0b27508f98 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -164,13 +164,15 @@ func NormalizeChannel(ch string) string { // extensionMetadata holds resolved metadata for feed generation. // Fields are resolved with priority: custom field → config table → default. type extensionMetadata struct { - Element string - DisplayName string - ExtType string - TargetVersion string - PHPMinimum string - Description string - SupportURL string + Element string + DisplayName string + ExtType string + TargetVersion string + PHPMinimum string + Description string + SupportURL string + DownloadGating string + KeyPrefix string } // resolveExtensionMetadata loads extension metadata with cascading fallback: @@ -206,6 +208,12 @@ func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, if cfg.SupportURL != "" { m.SupportURL = cfg.SupportURL } + if cfg.DownloadGating != "" { + m.DownloadGating = cfg.DownloadGating + } + if cfg.KeyPrefix != "" { + m.KeyPrefix = cfg.KeyPrefix + } } // Override with custom field values (highest priority). @@ -244,11 +252,14 @@ func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, if v := named["Support URL"]; v != "" { m.SupportURL = v } - if v := named["Download Gating"]; v != "" && cfg != nil { - cfg.DownloadGating = v + if v := named["Description"]; v != "" { + m.Description = v } - if v := named["Key Prefix"]; v != "" && cfg != nil { - cfg.KeyPrefix = v + if v := named["Download Gating"]; v != "" { + m.DownloadGating = v + } + if v := named["Key Prefix"]; v != "" { + m.KeyPrefix = v } return m -- 2.52.0 From 8e0388c9d8059e410f540d38ebfd6e0ee92005c8 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 4 Jun 2026 22:14:10 -0500 Subject: [PATCH 5/5] fix(custom-fields): log errors instead of silently discarding them - saveCustomFieldsFromForm: log GetCustomFieldsByOwner errors - resolveExtensionMetadata: log DB errors on custom field lookup - NewIssue/ViewIssue: log errors from GetCustomFieldsByOwner and GetCustomFieldValuesMap instead of blank-assigning - Composer: fix misleading comment about override source Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/web/repo/issue_new.go | 11 +++++++++-- routers/web/repo/issue_view.go | 11 +++++++++-- services/updateserver/composer.go | 2 +- services/updateserver/joomla.go | 13 +++++++++++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index d9993f8bf4..0fa87b1bb6 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -143,7 +143,10 @@ func NewIssue(ctx *context.Context) { ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues) // Load org-level issue-scoped custom fields for the new issue sidebar. - customFieldDefs, _ := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) + customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) + if cfErr != nil { + log.Error("NewIssue: GetCustomFieldsByOwner: %v", cfErr) + } ctx.Data["CustomFieldDefs"] = customFieldDefs customFieldValues := make(map[int64]string) fieldOptions := make(map[int64][]string) @@ -428,7 +431,11 @@ func NewIssuePost(ctx *context.Context) { // (submitted as "custom-field-{fieldID}") and persists them for the issue. func saveCustomFieldsFromForm(ctx *context.Context, ownerID, issueID int64) { defs, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue) - if err != nil || len(defs) == 0 { + if err != nil { + log.Error("saveCustomFieldsFromForm: GetCustomFieldsByOwner: %v", err) + return + } + if len(defs) == 0 { return } vals := make(map[int64]string) diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 672df411e7..4803827c4c 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -340,12 +340,19 @@ func ViewIssue(ctx *context.Context) { ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled // Load custom fields for the issue sidebar (org-level issue-scoped fields). - customFieldDefs, _ := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) + customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue) + if cfErr != nil { + log.Error("ViewIssue: GetCustomFieldsByOwner: %v", cfErr) + } ctx.Data["CustomFieldDefs"] = customFieldDefs customFieldValues := make(map[int64]string) fieldOptions := make(map[int64][]string) if len(customFieldDefs) > 0 { - customFieldValues, _ = issues_model.GetCustomFieldValuesMap(ctx, issue.ID) + var cvErr error + customFieldValues, cvErr = issues_model.GetCustomFieldValuesMap(ctx, issue.ID) + if cvErr != nil { + log.Error("ViewIssue: GetCustomFieldValuesMap: %v", cvErr) + } for _, f := range customFieldDefs { if f.Options != "" { var opts []string diff --git a/services/updateserver/composer.go b/services/updateserver/composer.go index 1833f2c4fc..0bdca68ac8 100644 --- a/services/updateserver/composer.go +++ b/services/updateserver/composer.go @@ -70,7 +70,7 @@ func GenerateComposerJSON(ctx context.Context, repo *repo_model.Repository, lice cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) meta := resolveExtensionMetadata(ctx, repo, cfg) - // Composer package name: vendor/package (override with custom field "Extension Name") + // Composer package name: vendor/package (override with resolved extension name if set) packageName := fmt.Sprintf("%s/%s", strings.ToLower(repo.Owner.Name), strings.ToLower(repo.Name)) if meta.Element != strings.ToLower(repo.Name) { packageName = meta.Element diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 0b27508f98..b64edf9fd1 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -16,6 +16,7 @@ import ( issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" ) @@ -218,11 +219,19 @@ func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, // Override with custom field values (highest priority). fields, err := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeRepo) - if err != nil || len(fields) == 0 { + if err != nil { + log.Error("resolveExtensionMetadata: GetCustomFieldsByOwner for repo %d: %v", repo.ID, err) + return m + } + if len(fields) == 0 { return m } values, err := issues_model.GetCustomFieldValuesMap(ctx, repo.ID) - if err != nil || len(values) == 0 { + if err != nil { + log.Error("resolveExtensionMetadata: GetCustomFieldValuesMap for repo %d: %v", repo.ID, err) + return m + } + if len(values) == 0 { return m } -- 2.52.0