From 16b47f5362a2839dad94c580f8dd2475bb6976b3 Mon Sep 17 00:00:00 2001 From: logikonline Date: Sun, 25 Jan 2026 22:40:34 -0500 Subject: [PATCH] feat(packages): add package defaults configuration for orgs Add ability for organizations to preconfigure default package metadata (authors, company, copyright, icon) that AI tools can use when building packages. Includes database model, org settings UI with icon upload, MCP tool for retrieving defaults with repo-specific URLs, and localization strings. --- models/packages/package_defaults.go | 56 +++++++++++++++++ options/locale/custom_keys.json | 13 ++++ options/locale/locale_en-US.json | 13 ++++ routers/api/v2/mcp.go | 60 ++++++++++++++++++ routers/web/org/setting_packages.go | 93 ++++++++++++++++++++++++++++ routers/web/user/package_icon.go | 45 ++++++++++++++ routers/web/web.go | 5 ++ templates/org/settings/packages.tmpl | 34 ++++++++++ 8 files changed, 319 insertions(+) create mode 100644 models/packages/package_defaults.go create mode 100644 routers/web/user/package_icon.go diff --git a/models/packages/package_defaults.go b/models/packages/package_defaults.go new file mode 100644 index 0000000000..617e33fc07 --- /dev/null +++ b/models/packages/package_defaults.go @@ -0,0 +1,56 @@ +// Copyright 2026 The GitCaddy Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" +) + +func init() { + db.RegisterModel(new(PackageDefaults)) +} + +// PackageDefaults stores preconfigured package metadata defaults for an organization or user. +// These values are used by AI tools when building packages (e.g., NuGet .csproj metadata). +type PackageDefaults struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"UNIQUE INDEX NOT NULL"` + Authors string `xorm:"TEXT"` + Company string `xorm:"VARCHAR(255)"` + Copyright string `xorm:"VARCHAR(512)"` + IconPath string `xorm:"VARCHAR(255)"` // relative path in avatar storage (package-icons/{ownerID}) +} + +// GetPackageDefaultsByOwnerID returns the package defaults for the given owner. +// Returns an empty PackageDefaults (with OwnerID set) if none exist yet. +func GetPackageDefaultsByOwnerID(ctx context.Context, ownerID int64) (*PackageDefaults, error) { + defaults := &PackageDefaults{OwnerID: ownerID} + has, err := db.GetEngine(ctx).Where("owner_id = ?", ownerID).Get(defaults) + if err != nil { + return nil, err + } + if !has { + return &PackageDefaults{OwnerID: ownerID}, nil + } + return defaults, nil +} + +// CreateOrUpdatePackageDefaults creates or updates the package defaults for the given owner. +func CreateOrUpdatePackageDefaults(ctx context.Context, defaults *PackageDefaults) error { + existing := &PackageDefaults{} + has, err := db.GetEngine(ctx).Where("owner_id = ?", defaults.OwnerID).Get(existing) + if err != nil { + return err + } + + if has { + defaults.ID = existing.ID + _, err = db.GetEngine(ctx).ID(defaults.ID).AllCols().Update(defaults) + } else { + _, err = db.GetEngine(ctx).Insert(defaults) + } + return err +} diff --git a/options/locale/custom_keys.json b/options/locale/custom_keys.json index 006e67f816..b271a16145 100644 --- a/options/locale/custom_keys.json +++ b/options/locale/custom_keys.json @@ -504,6 +504,19 @@ "packages.settings.unlink.error": "Failed to remove repository link.", "packages.settings.unlink.success": "Repository link was successfully removed.", "packages.settings.global_access.url": "Global URL", + "packages.settings.preconfigure": "Preconfigure Defaults", + "packages.settings.preconfigure.description": "Set default package metadata for this organization. These values are used by AI tools when building packages.", + "packages.settings.preconfigure.authors": "Authors", + "packages.settings.preconfigure.authors.placeholder": "e.g. David H. Friedel Jr", + "packages.settings.preconfigure.company": "Company", + "packages.settings.preconfigure.company.placeholder": "e.g. MarketAlly", + "packages.settings.preconfigure.copyright": "Copyright", + "packages.settings.preconfigure.copyright.placeholder": "e.g. Copyright © 2026 MarketAlly", + "packages.settings.preconfigure.icon": "Default Package Icon", + "packages.settings.preconfigure.icon.upload": "Upload Icon", + "packages.settings.preconfigure.saved": "Package defaults saved successfully.", + "packages.settings.preconfigure.icon.saved": "Package icon uploaded successfully.", + "packages.settings.preconfigure.icon.error": "Failed to upload package icon.", "packages.owner.settings.cargo.rebuild.success": "The Cargo index was successfully rebuilt.", "secrets.secrets": "Secrets", "actions.actions": "Actions", diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index e8c575dbef..c7a9f73f7c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3682,6 +3682,19 @@ "packages.settings.global_access.disabled": "Package global access has been disabled.", "packages.settings.global_access.error": "Failed to update global access setting.", "packages.settings.global_access.url": "Global URL", + "packages.settings.preconfigure": "Preconfigure Defaults", + "packages.settings.preconfigure.description": "Set default package metadata for this organization. These values are used by AI tools when building packages.", + "packages.settings.preconfigure.authors": "Authors", + "packages.settings.preconfigure.authors.placeholder": "e.g. David H. Friedel Jr", + "packages.settings.preconfigure.company": "Company", + "packages.settings.preconfigure.company.placeholder": "e.g. MarketAlly", + "packages.settings.preconfigure.copyright": "Copyright", + "packages.settings.preconfigure.copyright.placeholder": "e.g. Copyright © 2026 MarketAlly", + "packages.settings.preconfigure.icon": "Default Package Icon", + "packages.settings.preconfigure.icon.upload": "Upload Icon", + "packages.settings.preconfigure.saved": "Package defaults saved successfully.", + "packages.settings.preconfigure.icon.saved": "Package icon uploaded successfully.", + "packages.settings.preconfigure.icon.error": "Failed to upload package icon.", "packages.visibility": "Visibility", "packages.settings.visibility.private.text": "This package is currently private. Make it public to allow anyone to access it.", "packages.settings.visibility.private.button": "Make Private", diff --git a/routers/api/v2/mcp.go b/routers/api/v2/mcp.go index d13d29bf74..57a3101978 100644 --- a/routers/api/v2/mcp.go +++ b/routers/api/v2/mcp.go @@ -500,6 +500,24 @@ var mcpTools = []MCPTool{ "required": []string{"owner", "repo", "run_id", "artifact_name"}, }, }, + { + Name: "get_package_defaults", + Description: "Get preconfigured package defaults for an organization, with repo-specific URLs filled in. Returns authors, company, copyright, icon URL, and repository URLs for building valid package metadata.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Organization or user name", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name (optional, for generating repo-specific URLs)", + }, + }, + "required": []string{"owner"}, + }, + }, } // MCPHandler handles MCP protocol requests @@ -623,6 +641,8 @@ func handleToolsCall(ctx *context_service.APIContext, req *MCPRequest) { result, err = toolGetCompatibilityMatrix(ctx, params.Arguments) case "diagnose_job_failure": result, err = toolDiagnoseJobFailure(ctx, params.Arguments) + case "get_package_defaults": + result, err = toolGetPackageDefaults(ctx, params.Arguments) default: sendMCPError(ctx, req.ID, -32602, "Unknown tool", params.Name) return @@ -2004,3 +2024,43 @@ func toolGetArtifactDownloadURL(ctx *context_service.APIContext, args map[string "download_url": downloadURL, }, nil } + +func toolGetPackageDefaults(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + if owner == "" { + return nil, errors.New("owner is required") + } + + ownerUser, err := user_model.GetUserByName(ctx, owner) + if err != nil { + return nil, fmt.Errorf("owner not found: %s", owner) + } + + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ownerUser.ID) + if err != nil { + return nil, fmt.Errorf("failed to get package defaults: %w", err) + } + + result := map[string]any{ + "owner": owner, + "authors": defaults.Authors, + "company": defaults.Company, + "copyright": defaults.Copyright, + } + + // Fill in repo-specific URLs if repo is provided + if repo != "" { + baseURL := setting.AppURL + owner + "/" + repo + result["repository_url"] = baseURL + result["package_project_url"] = baseURL + } + + // Include icon URL if an icon has been uploaded + if defaults.IconPath != "" { + result["icon_url"] = setting.AppURL + owner + "/-/package-icon" + } + + return result, nil +} diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go index 3173989cbd..623cedb1b4 100644 --- a/routers/web/org/setting_packages.go +++ b/routers/web/org/setting_packages.go @@ -5,10 +5,15 @@ package org import ( "fmt" + "io" "net/http" + packages_model "code.gitcaddy.com/server/v3/models/packages" + "code.gitcaddy.com/server/v3/modules/avatar" "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/storage" "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/modules/typesniffer" shared "code.gitcaddy.com/server/v3/routers/web/shared/packages" shared_user "code.gitcaddy.com/server/v3/routers/web/shared/user" "code.gitcaddy.com/server/v3/services/context" @@ -30,11 +35,99 @@ func Packages(ctx *context.Context) { return } + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + ctx.Data["PackageDefaults"] = defaults + shared.SetPackagesContext(ctx, ctx.ContextUser) ctx.HTML(http.StatusOK, tplSettingsPackages) } +func PackageDefaultsPost(ctx *context.Context) { + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + + defaults.Authors = ctx.FormString("authors") + defaults.Company = ctx.FormString("company") + defaults.Copyright = ctx.FormString("copyright") + + if err := packages_model.CreateOrUpdatePackageDefaults(ctx, defaults); err != nil { + ctx.ServerError("CreateOrUpdatePackageDefaults", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.settings.preconfigure.saved")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} + +func PackageDefaultsIconPost(ctx *context.Context) { + file, header, err := ctx.Req.FormFile("icon") + if err != nil { + ctx.Flash.Error(ctx.Tr("packages.settings.preconfigure.icon.error")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) + return + } + defer file.Close() + + if header.Size > setting.Avatar.MaxFileSize { + ctx.Flash.Error(ctx.Tr("settings.uploaded_avatar_is_too_big", header.Size/1024, setting.Avatar.MaxFileSize/1024)) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) + return + } + + data, err := io.ReadAll(file) + if err != nil { + ctx.ServerError("io.ReadAll", err) + return + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + ctx.Flash.Error(ctx.Tr("settings.uploaded_avatar_not_a_image")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) + return + } + + // Process/resize the image + processedData, err := avatar.ProcessAvatarImage(data) + if err != nil { + ctx.ServerError("ProcessAvatarImage", err) + return + } + + // Save to storage + iconPath := fmt.Sprintf("package-icons/%d", ctx.ContextUser.ID) + if err := storage.SaveFrom(storage.Avatars, iconPath, func(w io.Writer) error { + _, err := w.Write(processedData) + return err + }); err != nil { + ctx.ServerError("storage.SaveFrom", err) + return + } + + // Update database + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + defaults.IconPath = iconPath + if err := packages_model.CreateOrUpdatePackageDefaults(ctx, defaults); err != nil { + ctx.ServerError("CreateOrUpdatePackageDefaults", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.settings.preconfigure.icon.saved")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} + func PackagesRuleAdd(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsOrgSettings"] = true diff --git a/routers/web/user/package_icon.go b/routers/web/user/package_icon.go new file mode 100644 index 0000000000..2934acff26 --- /dev/null +++ b/routers/web/user/package_icon.go @@ -0,0 +1,45 @@ +// Copyright 2026 The GitCaddy Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + "time" + + packages_model "code.gitcaddy.com/server/v3/models/packages" + "code.gitcaddy.com/server/v3/modules/httpcache" + "code.gitcaddy.com/server/v3/modules/storage" + "code.gitcaddy.com/server/v3/services/context" +) + +// PackageIcon serves the default package icon for an organization or user. +func PackageIcon(ctx *context.Context) { + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + + if defaults.IconPath == "" { + ctx.Status(http.StatusNotFound) + return + } + + f, err := storage.Avatars.Open(defaults.IconPath) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + ctx.ServerError("Stat", err) + return + } + + httpcache.SetCacheControlInHeader(ctx.Resp.Header(), &httpcache.CacheControlOptions{MaxAge: 5 * time.Minute}) + ctx.Resp.Header().Set("Content-Type", "image/png") + http.ServeContent(ctx.Resp, ctx.Req, "package-icon.png", info.ModTime(), f) +} diff --git a/routers/web/web.go b/routers/web/web.go index dfa63e1656..bd6fd5f224 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1080,6 +1080,10 @@ func registerWebRoutes(m *web.Router) { m.Post("/initialize", org.InitializeCargoIndex) m.Post("/rebuild", org.RebuildCargoIndex) }) + m.Group("/defaults", func() { + m.Post("", org.PackageDefaultsPost) + m.Post("/icon", org.PackageDefaultsIconPost) + }) }, packagesEnabled) m.Group("/blocked_users", func() { @@ -1118,6 +1122,7 @@ func registerWebRoutes(m *web.Router) { }) }) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) + m.Get("/package-icon", user.PackageIcon) } m.Get("/repositories", org.Repositories) diff --git a/templates/org/settings/packages.tmpl b/templates/org/settings/packages.tmpl index 91106c3f15..2a05c00636 100644 --- a/templates/org/settings/packages.tmpl +++ b/templates/org/settings/packages.tmpl @@ -1,5 +1,39 @@ {{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings packages")}}
+

+ {{ctx.Locale.Tr "packages.settings.preconfigure"}} +

+
+

{{ctx.Locale.Tr "packages.settings.preconfigure.description"}}

+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + +
+
+ + +
+ +
+
+
{{ctx.Locale.Tr "packages.settings.preconfigure.icon"}}
+
+ {{if .PackageDefaults.IconPath}} + + {{end}} +
+ {{.CsrfTokenHtml}} + + +
+
+
{{template "package/shared/cleanup_rules/list" .}} {{template "package/shared/cargo" .}}