2
0

feat(packages): add package defaults configuration for orgs
Some checks failed
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Failing after 3m5s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m59s
Build and Release / Lint (push) Successful in 5m6s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped

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.
This commit is contained in:
2026-01-25 22:40:34 -05:00
parent fa22455cad
commit 16b47f5362
8 changed files with 319 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -1,5 +1,39 @@
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings packages")}}
<div class="org-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "packages.settings.preconfigure"}}
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "packages.settings.preconfigure.description"}}</p>
<form class="ui form" action="{{.OrgLink}}/settings/packages/defaults" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<label>{{ctx.Locale.Tr "packages.settings.preconfigure.authors"}}</label>
<input name="authors" value="{{.PackageDefaults.Authors}}" placeholder="{{ctx.Locale.Tr "packages.settings.preconfigure.authors.placeholder"}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "packages.settings.preconfigure.company"}}</label>
<input name="company" value="{{.PackageDefaults.Company}}" placeholder="{{ctx.Locale.Tr "packages.settings.preconfigure.company.placeholder"}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "packages.settings.preconfigure.copyright"}}</label>
<input name="copyright" value="{{.PackageDefaults.Copyright}}" placeholder="{{ctx.Locale.Tr "packages.settings.preconfigure.copyright.placeholder"}}">
</div>
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</form>
<div class="ui divider"></div>
<h5>{{ctx.Locale.Tr "packages.settings.preconfigure.icon"}}</h5>
<div class="tw-flex tw-items-center tw-gap-4">
{{if .PackageDefaults.IconPath}}
<img class="ui tiny image" src="{{AppSubUrl}}/{{.ContextUser.Name}}/-/package-icon" width="64" height="64">
{{end}}
<form class="ui form" action="{{.OrgLink}}/settings/packages/defaults/icon" method="post" enctype="multipart/form-data">
{{.CsrfTokenHtml}}
<input name="icon" type="file" accept="image/png,image/jpeg,image/gif">
<button class="ui primary button tw-mt-2">{{ctx.Locale.Tr "packages.settings.preconfigure.icon.upload"}}</button>
</form>
</div>
</div>
{{template "package/shared/cleanup_rules/list" .}}
{{template "package/shared/cargo" .}}
</div>