feat(settings): repo manifest settings with auto-migration and API (#315) #504

Merged
jmiller merged 2 commits from feat/315-manifest-settings into dev 2026-06-06 14:15:18 +00:00
10 changed files with 518 additions and 0 deletions
+1
View File
@@ -424,6 +424,7 @@ func prepareMigrationTasks() []*migration {
newMigration(344, "Add domain_restriction to license_package table", v1_27.AddDomainRestrictionToLicensePackage),
newMigration(345, "Migrate custom fields to org-level with scope", v1_27.MigrateCustomFieldsToOrgLevel),
newMigration(346, "Add issue status definitions table", v1_27.AddIssueStatusDefTable),
newMigration(347, "Add repo manifest table", v1_27.AddRepoManifestTable),
}
return preparedMigrations
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"xorm.io/xorm"
)
// AddRepoManifestTable creates the repo_manifest table for storing
// moko-platform manifest settings per repository.
func AddRepoManifestTable(x *xorm.Engine) error {
type RepoManifest struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
Name string `xorm:"TEXT 'name'"`
Org string `xorm:"TEXT 'org'"`
Description string `xorm:"TEXT 'description'"`
Version string `xorm:"TEXT 'version'"`
LicenseSPDX string `xorm:"VARCHAR(50) 'license_spdx'"`
LicenseName string `xorm:"TEXT 'license_name'"`
Platform string `xorm:"VARCHAR(50) 'platform'"`
StandardsVersion string `xorm:"VARCHAR(20) 'standards_version'"`
StandardsSource string `xorm:"TEXT 'standards_source'"`
Language string `xorm:"VARCHAR(50) 'language'"`
PackageType string `xorm:"VARCHAR(50) 'package_type'"`
EntryPoint string `xorm:"TEXT 'entry_point'"`
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
}
return x.Sync(new(RepoManifest))
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"context"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(RepoManifest))
}
// RepoManifest stores moko-platform manifest settings for a repository.
// These fields correspond to the .mokogitea/manifest.xml schema and are
// exposed via API for use by Actions workflows and the moko-platform CLI.
type RepoManifest struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
// identity section
Name string `xorm:"TEXT 'name'"` // project name
Org string `xorm:"TEXT 'org'"` // organization name
Description string `xorm:"TEXT 'description'"` // project description
Version string `xorm:"TEXT 'version'"` // current version string
LicenseSPDX string `xorm:"VARCHAR(50) 'license_spdx'"` // SPDX identifier, e.g. "GPL-3.0-or-later"
LicenseName string `xorm:"TEXT 'license_name'"` // human-readable license name
// governance section
Platform string `xorm:"VARCHAR(50) 'platform'"` // go, php, node, python, etc.
StandardsVersion string `xorm:"VARCHAR(20) 'standards_version'"` // moko-platform standards version
StandardsSource string `xorm:"TEXT 'standards_source'"` // URL to standards repo
// build section
Language string `xorm:"VARCHAR(50) 'language'"` // Go, PHP, TypeScript, etc.
PackageType string `xorm:"VARCHAR(50) 'package_type'"` // application, library, plugin, module, component, package
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
}
func (RepoManifest) TableName() string {
return "repo_manifest"
}
// GetRepoManifest returns the manifest for a repo, or nil if none exists.
func GetRepoManifest(ctx context.Context, repoID int64) (*RepoManifest, error) {
m := new(RepoManifest)
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(m)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return m, nil
}
// CreateOrUpdateRepoManifest upserts a repo manifest.
func CreateOrUpdateRepoManifest(ctx context.Context, m *RepoManifest) error {
existing := new(RepoManifest)
has, err := db.GetEngine(ctx).Where("repo_id = ?", m.RepoID).Get(existing)
if err != nil {
return err
}
if has {
m.ID = existing.ID
_, err = db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)
return err
}
_, err = db.GetEngine(ctx).Insert(m)
return err
}
// DeleteRepoManifest deletes the manifest for a repo.
func DeleteRepoManifest(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(RepoManifest))
return err
}
+19
View File
@@ -2729,6 +2729,25 @@
"repo.settings.support_url": "Support / Product Page URL",
"repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.",
"repo.settings.custom_fields": "Custom Fields",
"repo.settings.manifest": "Manifest",
"repo.settings.manifest_desc": "Project identity, governance, and build settings from the moko-platform manifest. These are accessible via API for Actions workflows and the moko-platform CLI.",
"repo.settings.manifest_identity": "Identity",
"repo.settings.manifest_name": "Project Name",
"repo.settings.manifest_org": "Organization",
"repo.settings.manifest_description": "Description",
"repo.settings.manifest_version": "Version",
"repo.settings.manifest_license_spdx": "License (SPDX)",
"repo.settings.manifest_license_name": "License Name",
"repo.settings.manifest_governance": "Governance",
"repo.settings.manifest_platform": "Platform",
"repo.settings.manifest_standards_version": "Standards Version",
"repo.settings.manifest_standards_source": "Standards Source",
"repo.settings.manifest_build": "Build",
"repo.settings.manifest_language": "Language",
"repo.settings.manifest_package_type": "Package Type",
"repo.settings.manifest_entry_point": "Entry Point",
"repo.settings.manifest_save": "Save Manifest",
"repo.settings.manifest_saved": "Manifest settings saved.",
"repo.settings.metadata": "Metadata",
"repo.settings.metadata_saved": "Repository metadata saved.",
"repo.settings.metadata_empty": "No metadata fields defined. Org admins can add fields in Organization Settings > Custom Fields.",
+3
View File
@@ -1479,6 +1479,9 @@ func Routes() *web.Router {
Delete(reqToken(), repo.DeleteTopic)
}, reqAdmin())
}, reqAnyRepoReader())
m.Combo("/manifest", reqRepoReader(unit.TypeCode)).
Get(repo.GetRepoManifest).
Put(reqToken(), reqAdmin(), repo.UpdateRepoManifest)
// MokoGitea badge engine
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"encoding/json"
"net/http"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// apiManifest is the JSON representation of a repo manifest.
type apiManifest struct {
Name string `json:"name"`
Org string `json:"org"`
Description string `json:"description"`
Version string `json:"version"`
LicenseSPDX string `json:"license_spdx"`
LicenseName string `json:"license_name"`
Platform string `json:"platform"`
StandardsVersion string `json:"standards_version"`
StandardsSource string `json:"standards_source"`
Language string `json:"language"`
PackageType string `json:"package_type"`
EntryPoint string `json:"entry_point"`
}
// GetRepoManifest returns the manifest settings for a repository.
func GetRepoManifest(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/manifest repository repoGetManifest
// ---
// summary: Get repo manifest settings
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/Manifest"
// "404":
// "$ref": "#/responses/notFound"
m, err := repo_model.GetRepoManifest(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if m == nil {
// Return defaults from repo metadata.
ctx.JSON(http.StatusOK, &apiManifest{
Name: ctx.Repo.Repository.Name,
Org: ctx.Repo.Repository.OwnerName,
Description: ctx.Repo.Repository.Description,
})
return
}
ctx.JSON(http.StatusOK, &apiManifest{
Name: m.Name,
Org: m.Org,
Description: m.Description,
Version: m.Version,
LicenseSPDX: m.LicenseSPDX,
LicenseName: m.LicenseName,
Platform: m.Platform,
StandardsVersion: m.StandardsVersion,
StandardsSource: m.StandardsSource,
Language: m.Language,
PackageType: m.PackageType,
EntryPoint: m.EntryPoint,
})
}
// UpdateRepoManifest updates the manifest settings for a repository.
func UpdateRepoManifest(ctx *context.APIContext) {
// swagger:operation PUT /repos/{owner}/{repo}/manifest repository repoUpdateManifest
// ---
// summary: Update repo manifest settings
// consumes:
// - application/json
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/Manifest"
var req apiManifest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, err)
return
}
m := &repo_model.RepoManifest{
RepoID: ctx.Repo.Repository.ID,
Name: req.Name,
Org: req.Org,
Description: req.Description,
Version: req.Version,
LicenseSPDX: req.LicenseSPDX,
LicenseName: req.LicenseName,
Platform: req.Platform,
StandardsVersion: req.StandardsVersion,
StandardsSource: req.StandardsSource,
Language: req.Language,
PackageType: req.PackageType,
EntryPoint: req.EntryPoint,
}
if err := repo_model.CreateOrUpdateRepoManifest(ctx, m); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, &apiManifest{
Name: m.Name,
Org: m.Org,
Description: m.Description,
Version: m.Version,
LicenseSPDX: m.LicenseSPDX,
LicenseName: m.LicenseName,
Platform: m.Platform,
StandardsVersion: m.StandardsVersion,
StandardsSource: m.StandardsSource,
Language: m.Language,
PackageType: m.PackageType,
EntryPoint: m.EntryPoint,
})
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"encoding/xml"
"fmt"
"net/http"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
const tplSettingsManifest templates.TplName = "repo/settings/manifest"
// manifestXML mirrors the .mokogitea/manifest.xml schema for XML parsing.
type manifestXML struct {
XMLName xml.Name `xml:"moko-platform"`
Identity manifestIdentity `xml:"identity"`
Governance manifestGovernance `xml:"governance"`
Build manifestBuild `xml:"build"`
}
type manifestIdentity struct {
Name string `xml:"name"`
Org string `xml:"org"`
Description string `xml:"description"`
Version string `xml:"version"`
License manifestLicense `xml:"license"`
}
type manifestLicense struct {
SPDX string `xml:"spdx,attr"`
Name string `xml:",chardata"`
}
type manifestGovernance struct {
Platform string `xml:"platform"`
StandardsVersion string `xml:"standards-version"`
StandardsSource string `xml:"standards-source"`
}
type manifestBuild struct {
Language string `xml:"language"`
PackageType string `xml:"package-type"`
EntryPoint string `xml:"entry-point"`
}
// ManifestSettings displays the repo manifest settings page.
// On first visit, if no manifest exists in DB but .mokogitea/manifest.xml
// exists in the repo, it auto-migrates the XML values into the database.
func ManifestSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.manifest")
ctx.Data["PageIsSettingsManifest"] = true
repoID := ctx.Repo.Repository.ID
manifest, err := repo_model.GetRepoManifest(ctx, repoID)
if err != nil {
ctx.ServerError("GetRepoManifest", err)
return
}
// Auto-detect and migrate .mokogitea/manifest.xml if no DB record exists.
if manifest == nil {
manifest = tryMigrateManifestXML(ctx)
}
if manifest == nil {
// No manifest found — provide empty defaults from repo metadata.
manifest = &repo_model.RepoManifest{
RepoID: repoID,
Name: ctx.Repo.Repository.Name,
Org: ctx.Repo.Repository.OwnerName,
Description: ctx.Repo.Repository.Description,
}
}
ctx.Data["Manifest"] = manifest
ctx.HTML(http.StatusOK, tplSettingsManifest)
}
// ManifestSettingsPost saves manifest settings from the form.
func ManifestSettingsPost(ctx *context.Context) {
manifest := &repo_model.RepoManifest{
RepoID: ctx.Repo.Repository.ID,
Name: ctx.FormString("name"),
Org: ctx.FormString("org"),
Description: ctx.FormString("description"),
Version: ctx.FormString("version"),
LicenseSPDX: ctx.FormString("license_spdx"),
LicenseName: ctx.FormString("license_name"),
Platform: ctx.FormString("platform"),
StandardsVersion: ctx.FormString("standards_version"),
StandardsSource: ctx.FormString("standards_source"),
Language: ctx.FormString("language"),
PackageType: ctx.FormString("package_type"),
EntryPoint: ctx.FormString("entry_point"),
}
if err := repo_model.CreateOrUpdateRepoManifest(ctx, manifest); err != nil {
ctx.ServerError("CreateOrUpdateRepoManifest", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.manifest_saved"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/manifest")
}
// tryMigrateManifestXML reads .mokogitea/manifest.xml from the repo,
// parses it, and stores the values in the DB. Returns nil if no file found.
func tryMigrateManifestXML(ctx *context.Context) *repo_model.RepoManifest {
if ctx.Repo.GitRepo == nil || ctx.Repo.Commit == nil {
return nil
}
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(".mokogitea/manifest.xml")
if err != nil || entry == nil {
return nil // no manifest.xml found — not an error
}
reader, err := entry.Blob().DataAsync()
if err != nil {
log.Error("ManifestMigrate: read blob: %v", err)
return nil
}
defer reader.Close()
var mxml manifestXML
if err := xml.NewDecoder(reader).Decode(&mxml); err != nil {
log.Error("ManifestMigrate: parse XML: %v", err)
return nil
}
manifest := &repo_model.RepoManifest{
RepoID: ctx.Repo.Repository.ID,
Name: mxml.Identity.Name,
Org: mxml.Identity.Org,
Description: mxml.Identity.Description,
Version: mxml.Identity.Version,
LicenseSPDX: mxml.Identity.License.SPDX,
LicenseName: mxml.Identity.License.Name,
Platform: mxml.Governance.Platform,
StandardsVersion: mxml.Governance.StandardsVersion,
StandardsSource: mxml.Governance.StandardsSource,
Language: mxml.Build.Language,
PackageType: mxml.Build.PackageType,
EntryPoint: mxml.Build.EntryPoint,
}
if err := repo_model.CreateOrUpdateRepoManifest(ctx, manifest); err != nil {
log.Error("ManifestMigrate: save to DB: %v", err)
return nil
}
log.Info("ManifestMigrate: migrated .mokogitea/manifest.xml for repo %s/%s",
ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name)
ctx.Flash.Info(fmt.Sprintf("Manifest settings imported from .mokogitea/manifest.xml. You can now delete the file from the repository."))
return manifest
}
+1
View File
@@ -1199,6 +1199,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Combo("/advanced").Get(repo_setting.AdvancedSettings).Post(web.Bind(forms.RepoSettingForm{}), repo_setting.SettingsPost)
}, repo_setting.SettingsCtxData)
m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost)
m.Combo("/manifest").Get(repo_setting.ManifestSettings).Post(repo_setting.ManifestSettingsPost)
m.Combo("/metadata").Get(repo_setting.Metadata).Post(repo_setting.MetadataPost)
m.Group("/collaboration", func() {
+88
View File
@@ -0,0 +1,88 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings manifest")}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.manifest"}}
</h4>
<div class="ui attached segment">
<p class="text grey">{{ctx.Locale.Tr "repo.settings.manifest_desc"}}</p>
<form class="ui form" method="post" action="{{.RepoLink}}/settings/manifest">
{{.CsrfTokenHtml}}
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.manifest_identity"}}</h5>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_name"}}</label>
<input name="name" value="{{.Manifest.Name}}" placeholder="Project name">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_org"}}</label>
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_description"}}</label>
<input name="description" value="{{.Manifest.Description}}" placeholder="Project description">
</div>
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_version"}}</label>
<input name="version" value="{{.Manifest.Version}}" placeholder="e.g. 06.00.00">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_license_spdx"}}</label>
<input name="license_spdx" value="{{.Manifest.LicenseSPDX}}" placeholder="e.g. GPL-3.0-or-later">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_license_name"}}</label>
<input name="license_name" value="{{.Manifest.LicenseName}}" placeholder="e.g. GNU General Public License v3">
</div>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.manifest_governance"}}</h5>
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_platform"}}</label>
<select name="platform" class="ui dropdown">
<option value="">—</option>
{{$platform := .Manifest.Platform}}
{{range $val := StringUtils.Split "go,php,node,python,ruby,java,dotnet,rust" ","}}
<option value="{{$val}}" {{if eq $val $platform}}selected{{end}}>{{$val}}</option>
{{end}}
</select>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_standards_version"}}</label>
<input name="standards_version" value="{{.Manifest.StandardsVersion}}" placeholder="e.g. 05.00.00">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_standards_source"}}</label>
<input name="standards_source" value="{{.Manifest.StandardsSource}}" placeholder="URL to standards repo">
</div>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.manifest_build"}}</h5>
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_language"}}</label>
<input name="language" value="{{.Manifest.Language}}" placeholder="e.g. Go, PHP, TypeScript">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_package_type"}}</label>
<select name="package_type" class="ui dropdown">
<option value="">—</option>
{{$pkgType := .Manifest.PackageType}}
{{range $val := StringUtils.Split "application,library,plugin,module,component,package,template" ","}}
<option value="{{$val}}" {{if eq $val $pkgType}}selected{{end}}>{{$val}}</option>
{{end}}
</select>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.manifest_entry_point"}}</label>
<input name="entry_point" value="{{.Manifest.EntryPoint}}" placeholder="e.g. ./ or src/index.ts">
</div>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.settings.manifest_save"}}</button>
</form>
</div>
{{template "repo/settings/layout_footer" .}}
+3
View File
@@ -12,6 +12,9 @@
{{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}}
</a>
{{end}}
<a class="{{if .PageIsSettingsManifest}}active {{end}}item" href="{{.RepoLink}}/settings/manifest">
{{svg "octicon-file-code"}} {{ctx.Locale.Tr "repo.settings.manifest"}}
</a>
<a class="{{if .PageIsSettingsMetadata}}active {{end}}item" href="{{.RepoLink}}/settings/metadata">
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}}
</a>