diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index f40217fc30..6af0731f31 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v347.go b/models/migrations/v1_27/v347.go new file mode 100644 index 0000000000..20f2e027b3 --- /dev/null +++ b/models/migrations/v1_27/v347.go @@ -0,0 +1,32 @@ +// Copyright 2026 Moko Consulting +// 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)) +} diff --git a/models/repo/repo_manifest.go b/models/repo/repo_manifest.go new file mode 100644 index 0000000000..39b074702b --- /dev/null +++ b/models/repo/repo_manifest.go @@ -0,0 +1,83 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 8b6c158080..e19b9802eb 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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.", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2df3e5af6e..b47453b225 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/repo/manifest.go b/routers/api/v1/repo/manifest.go new file mode 100644 index 0000000000..ce8c45d6b7 --- /dev/null +++ b/routers/api/v1/repo/manifest.go @@ -0,0 +1,125 @@ +// Copyright 2026 Moko Consulting +// 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, + }) +} diff --git a/routers/web/repo/setting/manifest.go b/routers/web/repo/setting/manifest.go new file mode 100644 index 0000000000..f552cd43e8 --- /dev/null +++ b/routers/web/repo/setting/manifest.go @@ -0,0 +1,163 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/routers/web/web.go b/routers/web/web.go index 1f985c4e18..f50e1e076f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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() { diff --git a/templates/repo/settings/manifest.tmpl b/templates/repo/settings/manifest.tmpl new file mode 100644 index 0000000000..d0b0bdc1b4 --- /dev/null +++ b/templates/repo/settings/manifest.tmpl @@ -0,0 +1,88 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings manifest")}} +

+ {{ctx.Locale.Tr "repo.settings.manifest"}} +

+
+

{{ctx.Locale.Tr "repo.settings.manifest_desc"}}

+ +
+ {{.CsrfTokenHtml}} + +
{{ctx.Locale.Tr "repo.settings.manifest_identity"}}
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
{{ctx.Locale.Tr "repo.settings.manifest_governance"}}
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
{{ctx.Locale.Tr "repo.settings.manifest_build"}}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 0d7ac89133..071fb3b5fb 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -12,6 +12,9 @@ {{svg "octicon-broadcast"}} {{ctx.Locale.Tr "repo.settings.licensing_section"}} {{end}} + + {{svg "octicon-file-code"}} {{ctx.Locale.Tr "repo.settings.manifest"}} + {{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}}