From df9305758f3ce7044457b21108a4a4449707c32c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:03:15 -0500 Subject: [PATCH 1/9] feat: add issue status presets and cross-org migration (#507) 4 built-in presets: default, software-development, support-tickets, bug-tracking. API endpoints to list presets, apply to org, and copy statuses between orgs. Web UI dropdown on org settings page. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- CHANGELOG.md | 2 + models/issues/issue_status.go | 205 +++++++++++++++++++++ modules/structs/issue.go | 18 ++ options/locale/locale_en-US.json | 5 + routers/api/v1/api.go | 5 + routers/api/v1/org/issue_metadata.go | 105 +++++++++++ routers/web/org/issue_statuses.go | 26 +++ routers/web/web.go | 1 + templates/org/settings/issue_statuses.tmpl | 22 +++ 9 files changed, 389 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db6fd594a..9bce977a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### Added +- Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507) +- Cross-org status migration: copy status definitions from one org to another via API (#507) - Branch protection delete allowlist: configurable per-user/team/deploy-key allowlist for deleting protected branches (#696) - Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693) - API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697) diff --git a/models/issues/issue_status.go b/models/issues/issue_status.go index 57318f9fd2..a3b799d27f 100644 --- a/models/issues/issue_status.go +++ b/models/issues/issue_status.go @@ -33,6 +33,211 @@ func (IssueStatusDef) TableName() string { return "issue_status_def" } +// ────────────────────────────────────────────────────────────────────── +// Presets +// ────────────────────────────────────────────────────────────────────── + +// StatusPresetEntry defines a single status in a preset template. +type StatusPresetEntry struct { + Name string + Color string + Description string + ClosesIssue bool + IsRequired bool +} + +// StatusPreset defines a named collection of status definitions. +type StatusPreset struct { + Name string + Description string + Statuses []StatusPresetEntry +} + +// StatusPresets is the registry of built-in status presets. +var StatusPresets = map[string]*StatusPreset{ + "default": { + Name: "default", + Description: "General-purpose workflow (default seed)", + Statuses: []StatusPresetEntry{ + {Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true}, + {Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done"}, + {Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input"}, + {Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review"}, + {Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true}, + {Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true}, + }, + }, + "software-development": { + Name: "software-development", + Description: "Software development lifecycle", + Statuses: []StatusPresetEntry{ + {Name: "Open", Color: "#2563eb", Description: "New or active issue", IsRequired: true}, + {Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on this"}, + {Name: "In Review", Color: "#0891b2", Description: "Pull request submitted, awaiting review"}, + {Name: "Testing", Color: "#d97706", Description: "Being tested or in QA"}, + {Name: "Closed", Color: "#16a34a", Description: "Completed, merged, and deployed", ClosesIssue: true, IsRequired: true}, + {Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true}, + }, + }, + "support-tickets": { + Name: "support-tickets", + Description: "Customer support ticket workflow", + Statuses: []StatusPresetEntry{ + {Name: "New", Color: "#2563eb", Description: "Ticket received, not yet triaged", IsRequired: true}, + {Name: "Assigned", Color: "#7c3aed", Description: "Assigned to a support agent"}, + {Name: "Waiting for Customer", Color: "#f59e0b", Description: "Awaiting customer response"}, + {Name: "In Progress", Color: "#0891b2", Description: "Agent is actively working on this"}, + {Name: "Resolved", Color: "#16a34a", Description: "Issue resolved, awaiting confirmation", ClosesIssue: true}, + {Name: "Closed", Color: "#059669", Description: "Confirmed resolved", ClosesIssue: true, IsRequired: true}, + }, + }, + "bug-tracking": { + Name: "bug-tracking", + Description: "Bug lifecycle tracking", + Statuses: []StatusPresetEntry{ + {Name: "New", Color: "#2563eb", Description: "Bug reported, not yet triaged", IsRequired: true}, + {Name: "Confirmed", Color: "#dc2626", Description: "Bug confirmed and reproducible"}, + {Name: "In Progress", Color: "#7c3aed", Description: "Developer is working on a fix"}, + {Name: "Fixed", Color: "#0891b2", Description: "Fix implemented, awaiting verification"}, + {Name: "Verified", Color: "#16a34a", Description: "Fix verified by QA"}, + {Name: "Closed", Color: "#059669", Description: "Bug resolved and closed", ClosesIssue: true, IsRequired: true}, + {Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to fix", ClosesIssue: true}, + }, + }, +} + +// StatusPresetNames returns the list of available preset names in display order. +func StatusPresetNames() []string { + return []string{"default", "software-development", "support-tickets", "bug-tracking"} +} + +// ApplyStatusPreset replaces all non-required statuses for an org with a preset. +// Required statuses (Open/Closed) are preserved if they already exist; the preset's +// required entries are created if missing. Non-required statuses are soft-deleted +// (is_active=false) and the preset's non-required entries are inserted. +func ApplyStatusPreset(ctx context.Context, orgID int64, presetName string) error { + preset, ok := StatusPresets[presetName] + if !ok { + return db.ErrNotExist{Resource: "StatusPreset", ID: 0} + } + + existing, err := GetAllIssueStatusDefsByOrg(ctx, orgID) + if err != nil { + return err + } + + // Build lookup of existing statuses by name + existingByName := make(map[string]*IssueStatusDef, len(existing)) + for _, d := range existing { + existingByName[d.Name] = d + } + + // Deactivate all non-required existing statuses + for _, d := range existing { + if d.IsRequired { + continue + } + if d.IsActive { + d.IsActive = false + if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil { + return err + } + } + } + + // Apply preset entries + for i, entry := range preset.Statuses { + if ex, found := existingByName[entry.Name]; found { + // Update existing status to match preset + ex.Color = entry.Color + ex.Description = entry.Description + ex.ClosesIssue = entry.ClosesIssue + ex.SortOrder = i + ex.IsActive = true + if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil { + return err + } + } else { + // Create new status + def := &IssueStatusDef{ + OrgID: orgID, + Name: entry.Name, + Color: entry.Color, + Description: entry.Description, + ClosesIssue: entry.ClosesIssue, + IsRequired: entry.IsRequired, + SortOrder: i, + IsActive: true, + } + if _, err := db.GetEngine(ctx).Insert(def); err != nil { + return err + } + } + } + return nil +} + +// CopyStatusesFromOrg copies all active status definitions from srcOrgID to dstOrgID. +// Existing non-required statuses in dstOrgID are deactivated first. +func CopyStatusesFromOrg(ctx context.Context, srcOrgID, dstOrgID int64) error { + srcDefs, err := GetIssueStatusDefsByOrg(ctx, srcOrgID) + if err != nil { + return err + } + + existing, err := GetAllIssueStatusDefsByOrg(ctx, dstOrgID) + if err != nil { + return err + } + + existingByName := make(map[string]*IssueStatusDef, len(existing)) + for _, d := range existing { + existingByName[d.Name] = d + } + + // Deactivate non-required existing statuses + for _, d := range existing { + if d.IsRequired { + continue + } + if d.IsActive { + d.IsActive = false + if _, err := db.GetEngine(ctx).ID(d.ID).Cols("is_active").Update(d); err != nil { + return err + } + } + } + + // Copy source statuses + for _, src := range srcDefs { + if ex, found := existingByName[src.Name]; found { + ex.Color = src.Color + ex.Description = src.Description + ex.ClosesIssue = src.ClosesIssue + ex.SortOrder = src.SortOrder + ex.IsActive = true + if _, err := db.GetEngine(ctx).ID(ex.ID).AllCols().Update(ex); err != nil { + return err + } + } else { + def := &IssueStatusDef{ + OrgID: dstOrgID, + Name: src.Name, + Color: src.Color, + Description: src.Description, + ClosesIssue: src.ClosesIssue, + IsRequired: src.IsRequired, + SortOrder: src.SortOrder, + IsActive: true, + } + if _, err := db.GetEngine(ctx).Insert(def); err != nil { + return err + } + } + } + return nil +} + // ────────────────────────────────────────────────────────────────────── // Queries // ────────────────────────────────────────────────────────────────────── diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 002b46b8e1..1a62b8a767 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -169,6 +169,24 @@ type IssueStatusDef struct { SortOrder int `json:"sort_order"` } +// StatusPresetEntry represents a single status in a preset template. +// swagger:model +type StatusPresetEntry struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` + ClosesIssue bool `json:"closes_issue"` + IsRequired bool `json:"is_required"` +} + +// StatusPreset represents a named status preset template. +// swagger:model +type StatusPreset struct { + Name string `json:"name"` + Description string `json:"description"` + Statuses []*StatusPresetEntry `json:"statuses"` +} + // IssuePriorityDef represents an org-level issue priority definition // swagger:model type IssuePriorityDef struct { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f38506433a..ea52615095 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3009,6 +3009,11 @@ "org.settings.issue_status_created": "Issue status created.", "org.settings.issue_status_updated": "Issue status updated.", "org.settings.issue_status_deleted": "Issue status deleted.", + "org.settings.issue_status_presets": "Status Presets", + "org.settings.issue_status_presets_desc": "Apply a preset template to replace your current statuses. Required statuses (Open/Closed) are preserved; others are deactivated and replaced.", + "org.settings.issue_status_preset_apply": "Apply Preset", + "org.settings.issue_status_preset_confirm": "This will deactivate your current custom statuses and replace them with the selected preset. Required statuses are preserved. Continue?", + "org.settings.issue_status_preset_applied": "Status preset applied successfully.", "org.settings.issue_priorities": "Issue Priorities", "org.settings.issue_priorities_desc": "Define priority levels for all repositories in this organization. Priorities appear in the issue sidebar.", "org.settings.issue_priorities_empty": "No custom issue priorities defined yet.", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5433f11f25..3a37c0d115 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1782,6 +1782,11 @@ func Routes() *web.Router { m.Delete("/{id}", reqToken(), reqOrgOwnership(), org.DeleteOrgCustomField) }) m.Get("/issue-statuses", org.ListIssueStatuses) + m.Group("/issue-statuses", func() { + m.Get("/presets", org.ListIssueStatusPresets) + m.Post("/presets/{preset}", reqToken(), reqOrgOwnership(), org.ApplyIssueStatusPreset) + m.Post("/copy/{source_org}", reqToken(), reqOrgOwnership(), org.CopyIssueStatusesFromOrg) + }) m.Get("/issue-priorities", org.ListIssuePriorities) m.Get("/issue-types", org.ListIssueTypes) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) diff --git a/routers/api/v1/org/issue_metadata.go b/routers/api/v1/org/issue_metadata.go index 5566b457a0..b5bfe8aaa6 100644 --- a/routers/api/v1/org/issue_metadata.go +++ b/routers/api/v1/org/issue_metadata.go @@ -7,6 +7,7 @@ import ( "net/http" issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" + org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization" api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" ) @@ -162,3 +163,107 @@ func ListIssueTypes(ctx *context.APIContext) { } ctx.JSON(http.StatusOK, result) } + +// ListIssueStatusPresets returns the available status preset templates. +func ListIssueStatusPresets(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/issue-statuses/presets organization orgListIssueStatusPresets + // --- + // summary: List available issue status presets + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // description: "StatusPresetList" + + result := make([]*api.StatusPreset, 0, len(issues_model.StatusPresetNames())) + for _, name := range issues_model.StatusPresetNames() { + preset := issues_model.StatusPresets[name] + statuses := make([]*api.StatusPresetEntry, 0, len(preset.Statuses)) + for _, s := range preset.Statuses { + statuses = append(statuses, &api.StatusPresetEntry{ + Name: s.Name, + Color: s.Color, + Description: s.Description, + ClosesIssue: s.ClosesIssue, + IsRequired: s.IsRequired, + }) + } + result = append(result, &api.StatusPreset{ + Name: preset.Name, + Description: preset.Description, + Statuses: statuses, + }) + } + ctx.JSON(http.StatusOK, result) +} + +// ApplyIssueStatusPreset applies a status preset to an organization. +func ApplyIssueStatusPreset(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/issue-statuses/presets/{preset} organization orgApplyIssueStatusPreset + // --- + // summary: Apply a status preset to an organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: preset + // in: path + // description: preset name + // type: string + // required: true + // responses: + // "204": + // description: "StatusPresetApplied" + // "404": + // "$ref": "#/responses/notFound" + + presetName := ctx.PathParam("preset") + if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil { + ctx.APIErrorNotFound() + return + } + ctx.Status(http.StatusNoContent) +} + +// CopyIssueStatusesFromOrg copies status definitions from another organization. +func CopyIssueStatusesFromOrg(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/issue-statuses/copy/{source_org} organization orgCopyIssueStatuses + // --- + // summary: Copy issue statuses from another organization + // parameters: + // - name: org + // in: path + // description: target organization name + // type: string + // required: true + // - name: source_org + // in: path + // description: source organization name to copy from + // type: string + // required: true + // responses: + // "204": + // description: "StatusesCopied" + // "404": + // "$ref": "#/responses/notFound" + + sourceOrgName := ctx.PathParam("source_org") + sourceOrg, err := org_model.GetOrgByName(ctx, sourceOrgName) + if err != nil { + ctx.APIErrorNotFound() + return + } + if err := issues_model.CopyStatusesFromOrg(ctx, sourceOrg.ID, ctx.Org.Organization.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/org/issue_statuses.go b/routers/web/org/issue_statuses.go index 203d56b0a0..578689bedb 100644 --- a/routers/web/org/issue_statuses.go +++ b/routers/web/org/issue_statuses.go @@ -27,9 +27,35 @@ func SettingsIssueStatuses(ctx *context.Context) { } ctx.Data["IssueStatuses"] = defs + // Load preset names for the preset selector + presetNames := issues_model.StatusPresetNames() + type presetInfo struct { + Name string + Description string + } + presets := make([]presetInfo, 0, len(presetNames)) + for _, name := range presetNames { + p := issues_model.StatusPresets[name] + presets = append(presets, presetInfo{Name: p.Name, Description: p.Description}) + } + ctx.Data["StatusPresets"] = presets + ctx.HTML(http.StatusOK, tplOrgIssueStatuses) } +// SettingsIssueStatusesApplyPresetPost applies a status preset to the org. +func SettingsIssueStatusesApplyPresetPost(ctx *context.Context) { + presetName := ctx.FormString("preset") + if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil { + ctx.Flash.Error("Unknown preset: " + presetName) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.issue_status_preset_applied")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses") +} + // SettingsIssueStatusesCreatePost creates a new org-level issue status. func SettingsIssueStatusesCreatePost(ctx *context.Context) { sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order")) diff --git a/routers/web/web.go b/routers/web/web.go index 72660737f4..8f9840a22f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1091,6 +1091,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/issue-statuses", func() { m.Get("", org.SettingsIssueStatuses) m.Post("", org.SettingsIssueStatusesCreatePost) + m.Post("/apply-preset", org.SettingsIssueStatusesApplyPresetPost) m.Post("/{id}/edit", org.SettingsIssueStatusesEditPost) m.Post("/{id}/delete", org.SettingsIssueStatusesDeletePost) }) diff --git a/templates/org/settings/issue_statuses.tmpl b/templates/org/settings/issue_statuses.tmpl index 9d10ae8b64..bcb597807f 100644 --- a/templates/org/settings/issue_statuses.tmpl +++ b/templates/org/settings/issue_statuses.tmpl @@ -62,6 +62,28 @@
+
{{ctx.Locale.Tr "org.settings.issue_status_presets"}}
+

{{ctx.Locale.Tr "org.settings.issue_status_presets_desc"}}

+
+ {{.CsrfTokenHtml}} +
+
+ +
+
+ +
+
+
+ +
+
{{ctx.Locale.Tr "org.settings.issue_status_add"}}
{{.CsrfTokenHtml}} -- 2.52.0 From f627219ca847f76550ec03e402ab3693a2ecdf8d Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:04:40 -0500 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20cascade=20merge=20=E2=80=94=20auto-?= =?UTF-8?q?create=20PRs=20to=20downstream=20branches=20after=20merge=20(#4?= =?UTF-8?q?60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds configurable cascade rules per repo. When a PR merges into a source branch, the system auto-creates PRs to each configured target branch. Skips if a matching PR already exists. - Model: CascadeMergeRule (repo_id, source, target, enabled, auto_merge) - Migration v362 creates cascade_merge_rule table - Notifier hooks into MergePullRequest/AutoMergePullRequest events - API: CRUD at /repos/{owner}/{repo}/cascade_rules (admin only) Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- CHANGELOG.md | 1 + models/migrations/migrations.go | 1 + models/migrations/v1_27/v362.go | 24 +++++ models/repo/cascade.go | 63 +++++++++++++ modules/structs/repo_cascade.go | 33 +++++++ routers/api/v1/api.go | 17 ++++ routers/api/v1/repo/cascade.go | 151 ++++++++++++++++++++++++++++++++ routers/init.go | 2 + services/cascade/notifier.go | 103 ++++++++++++++++++++++ 9 files changed, 395 insertions(+) create mode 100644 models/migrations/v1_27/v362.go create mode 100644 models/repo/cascade.go create mode 100644 modules/structs/repo_cascade.go create mode 100644 routers/api/v1/repo/cascade.go create mode 100644 services/cascade/notifier.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db6fd594a..3c8c9d7ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460) - Branch protection delete allowlist: configurable per-user/team/deploy-key allowlist for deleting protected branches (#696) - Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693) - API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697) diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 8fd75de67a..557a1ede75 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -438,6 +438,7 @@ func prepareMigrationTasks() []*migration { newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables), newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest), newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch), + newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v362.go b/models/migrations/v1_27/v362.go new file mode 100644 index 0000000000..03b74339fa --- /dev/null +++ b/models/migrations/v1_27/v362.go @@ -0,0 +1,24 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddCascadeMergeRuleTable(x *xorm.Engine) error { + type CascadeMergeRule struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + Enabled bool `xorm:"NOT NULL DEFAULT true"` + AutoMerge bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + return x.Sync(new(CascadeMergeRule)) +} diff --git a/models/repo/cascade.go b/models/repo/cascade.go new file mode 100644 index 0000000000..8aa59a6f54 --- /dev/null +++ b/models/repo/cascade.go @@ -0,0 +1,63 @@ +// 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" +) + +type CascadeMergeRule struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + Enabled bool `xorm:"NOT NULL DEFAULT true"` + AutoMerge bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(CascadeMergeRule)) +} + +func GetCascadeRulesByRepoID(ctx context.Context, repoID int64) ([]*CascadeMergeRule, error) { + rules := make([]*CascadeMergeRule, 0) + return rules, db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&rules) +} + +func GetCascadeRulesForBranch(ctx context.Context, repoID int64, sourceBranch string) ([]*CascadeMergeRule, error) { + rules := make([]*CascadeMergeRule, 0) + return rules, db.GetEngine(ctx).Where("repo_id = ? AND source_branch = ? AND enabled = ?", repoID, sourceBranch, true).Find(&rules) +} + +func GetCascadeRuleByID(ctx context.Context, id int64) (*CascadeMergeRule, error) { + rule := &CascadeMergeRule{ID: id} + has, err := db.GetEngine(ctx).Get(rule) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return rule, nil +} + +func CreateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error { + _, err := db.GetEngine(ctx).Insert(rule) + return err +} + +func UpdateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error { + _, err := db.GetEngine(ctx).ID(rule.ID).Cols("source_branch", "target_branch", "enabled", "auto_merge").Update(rule) + return err +} + +func DeleteCascadeRule(ctx context.Context, repoID, id int64) error { + _, err := db.GetEngine(ctx).Where("id = ? AND repo_id = ?", id, repoID).Delete(&CascadeMergeRule{}) + return err +} diff --git a/modules/structs/repo_cascade.go b/modules/structs/repo_cascade.go new file mode 100644 index 0000000000..aaade87eaf --- /dev/null +++ b/modules/structs/repo_cascade.go @@ -0,0 +1,33 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package structs + +import "time" + +// CascadeMergeRule represents a cascade merge rule +type CascadeMergeRule struct { + ID int64 `json:"id"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Enabled bool `json:"enabled"` + AutoMerge bool `json:"auto_merge"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateCascadeMergeRuleOption options for creating a cascade merge rule +type CreateCascadeMergeRuleOption struct { + SourceBranch string `json:"source_branch" binding:"Required"` + TargetBranch string `json:"target_branch" binding:"Required"` + Enabled *bool `json:"enabled"` + AutoMerge *bool `json:"auto_merge"` +} + +// EditCascadeMergeRuleOption options for editing a cascade merge rule +type EditCascadeMergeRuleOption struct { + SourceBranch *string `json:"source_branch"` + TargetBranch *string `json:"target_branch"` + Enabled *bool `json:"enabled"` + AutoMerge *bool `json:"auto_merge"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5433f11f25..f87b19f5ac 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1262,6 +1262,23 @@ func Routes() *web.Router { }) m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories) }, reqToken(), reqAdmin()) + m.Group("/cascade_rules", func() { + m.Get("", repo.ListCascadeRules) + m.Post("", mustNotBeArchived, repo.CreateCascadeRule) + m.Group("/{id}", func() { + m.Get("", repo.GetCascadeRule) + m.Patch("", mustNotBeArchived, repo.EditCascadeRule) + m.Delete("", mustNotBeArchived, repo.DeleteCascadeRule) + }) + }, reqToken(), reqAdmin()) + m.Group("/security", func() { + m.Get("/alerts", repo.ListSecurityAlerts) + m.Get("/alerts/{id}", repo.GetSecurityAlert) + m.Patch("/alerts/{id}", reqToken(), reqAdmin(), repo.UpdateSecurityAlert) + m.Post("/scan", reqToken(), reqAdmin(), repo.TriggerSecurityScan) + m.Get("/config", repo.GetSecurityConfig) + m.Patch("/config", reqToken(), reqAdmin(), repo.UpdateSecurityConfig) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) m.Group("/tags", func() { m.Get("", repo.ListTags) m.Get("/*", repo.GetTag) diff --git a/routers/api/v1/repo/cascade.go b/routers/api/v1/repo/cascade.go new file mode 100644 index 0000000000..e5459ac29a --- /dev/null +++ b/routers/api/v1/repo/cascade.go @@ -0,0 +1,151 @@ +// 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" + api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +func toCascadeRuleAPI(rule *repo_model.CascadeMergeRule) *api.CascadeMergeRule { + return &api.CascadeMergeRule{ + ID: rule.ID, + SourceBranch: rule.SourceBranch, + TargetBranch: rule.TargetBranch, + Enabled: rule.Enabled, + AutoMerge: rule.AutoMerge, + CreatedAt: rule.CreatedUnix.AsTime(), + UpdatedAt: rule.UpdatedUnix.AsTime(), + } +} + +func ListCascadeRules(ctx *context.APIContext) { + rules, err := repo_model.GetCascadeRulesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiRules := make([]*api.CascadeMergeRule, len(rules)) + for i, rule := range rules { + apiRules[i] = toCascadeRuleAPI(rule) + } + ctx.JSON(http.StatusOK, apiRules) +} + +func CreateCascadeRule(ctx *context.APIContext) { + var req api.CreateCascadeMergeRuleOption + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusBadRequest, err) + return + } + + if req.SourceBranch == "" || req.TargetBranch == "" { + ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch are required") + return + } + + if req.SourceBranch == req.TargetBranch { + ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different") + return + } + + rule := &repo_model.CascadeMergeRule{ + RepoID: ctx.Repo.Repository.ID, + SourceBranch: req.SourceBranch, + TargetBranch: req.TargetBranch, + Enabled: true, + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if req.AutoMerge != nil { + rule.AutoMerge = *req.AutoMerge + } + + if err := repo_model.CreateCascadeRule(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, toCascadeRuleAPI(rule)) +} + +func GetCascadeRule(ctx *context.APIContext) { + rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil || rule.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule)) +} + +func EditCascadeRule(ctx *context.APIContext) { + rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil || rule.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + var req api.EditCascadeMergeRuleOption + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusBadRequest, err) + return + } + + if req.SourceBranch != nil { + rule.SourceBranch = *req.SourceBranch + } + if req.TargetBranch != nil { + rule.TargetBranch = *req.TargetBranch + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if req.AutoMerge != nil { + rule.AutoMerge = *req.AutoMerge + } + + if rule.SourceBranch == rule.TargetBranch { + ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different") + return + } + + if err := repo_model.UpdateCascadeRule(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule)) +} + +func DeleteCascadeRule(ctx *context.APIContext) { + rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil || rule.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + if err := repo_model.DeleteCascadeRule(ctx, ctx.Repo.Repository.ID, rule.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/init.go b/routers/init.go index a7742cebd4..0b6c0e5916 100644 --- a/routers/init.go +++ b/routers/init.go @@ -38,6 +38,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/automerge" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cascade" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cron" feed_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/feed" indexer_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/indexer" @@ -153,6 +154,7 @@ func InitWebInstalled(ctx context.Context) { mustInit(webhook.Init) mustInit(pull_service.Init) mustInit(automerge.Init) + cascade.Init() mustInit(task.Init) mustInit(repo_migrations.Init) eventsource.GetManager().Init() diff --git a/services/cascade/notifier.go b/services/cascade/notifier.go new file mode 100644 index 0000000000..93a6cdbcff --- /dev/null +++ b/services/cascade/notifier.go @@ -0,0 +1,103 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package cascade + +import ( + "context" + "fmt" + + issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" + notify_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/notify" + pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull" +) + +func Init() { + notify_service.RegisterNotifier(&cascadeNotifier{}) +} + +type cascadeNotifier struct { + notify_service.NullNotifier +} + +func (n *cascadeNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + handleCascade(ctx, doer, pr) +} + +func (n *cascadeNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + handleCascade(ctx, doer, pr) +} + +func handleCascade(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadBaseRepo(ctx); err != nil { + log.Error("cascade: LoadBaseRepo for PR #%d: %v", pr.Index, err) + return + } + + rules, err := repo_model.GetCascadeRulesForBranch(ctx, pr.BaseRepo.ID, pr.BaseBranch) + if err != nil { + log.Error("cascade: GetCascadeRulesForBranch repo=%d branch=%s: %v", pr.BaseRepo.ID, pr.BaseBranch, err) + return + } + + for _, rule := range rules { + if err := createCascadePR(ctx, doer, pr, rule); err != nil { + log.Error("cascade: failed to create PR %s→%s in repo %d: %v", rule.SourceBranch, rule.TargetBranch, rule.RepoID, err) + } + } +} + +func createCascadePR(ctx context.Context, doer *user_model.User, mergedPR *issues_model.PullRequest, rule *repo_model.CascadeMergeRule) error { + repo := mergedPR.BaseRepo + + existingPRs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repo.ID, rule.TargetBranch) + if err != nil { + return fmt.Errorf("check existing PRs: %w", err) + } + for _, existing := range existingPRs { + if existing.HeadBranch == rule.SourceBranch && existing.HeadRepoID == repo.ID { + log.Info("cascade: PR already exists %s→%s in %s/%s (#%d), skipping", + rule.SourceBranch, rule.TargetBranch, repo.OwnerName, repo.Name, existing.Index) + return nil + } + } + + title := fmt.Sprintf("cascade: merge %s into %s", rule.SourceBranch, rule.TargetBranch) + body := fmt.Sprintf("Auto-created by cascade merge rule after PR #%d was merged.\n\nSource: `%s` → Target: `%s`", + mergedPR.Index, rule.SourceBranch, rule.TargetBranch) + + issue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Title: title, + Content: body, + PosterID: doer.ID, + Poster: doer, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: repo.ID, + BaseRepoID: repo.ID, + HeadBranch: rule.SourceBranch, + BaseBranch: rule.TargetBranch, + HeadRepo: repo, + BaseRepo: repo, + Type: issues_model.PullRequestGitea, + } + + if err := pull_service.NewPullRequest(ctx, &pull_service.NewPullRequestOptions{ + Repo: repo, + Issue: issue, + PullRequest: pullRequest, + }); err != nil { + return fmt.Errorf("NewPullRequest: %w", err) + } + + log.Info("cascade: created PR #%d (%s→%s) in %s/%s", + issue.Index, rule.SourceBranch, rule.TargetBranch, repo.OwnerName, repo.Name) + return nil +} -- 2.52.0 From e99658ddc0d73114637766ed9350faf38f6432ed Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:00:17 -0500 Subject: [PATCH 3/9] feat(orgs): auto-create default teams on org creation (#513) New organizations now get three default teams in addition to Owners: - Developers (write: code, issues, PRs, wiki, projects; read: releases) - Reviewers (read: code, issues, PRs, releases, wiki) - CI/CD (write: actions, packages, releases; read: code) Teams are defined in DefaultOrgTeams and created inside the same transaction as the org, so creation is atomic. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- CHANGELOG.md | 1 + models/organization/org.go | 87 +++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db6fd594a..21568974ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- Auto-create default teams on org creation: Developers (write), Reviewers (read), CI/CD (actions+packages) (#513) - Branch protection delete allowlist: configurable per-user/team/deploy-key allowlist for deleting protected branches (#696) - Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693) - API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697) diff --git a/models/organization/org.go b/models/organization/org.go index ce174e1656..ef5d212b3b 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -323,6 +323,60 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us } // CreateOrganization creates record of a new organization. +// DefaultTeamSpec defines a team to auto-create when a new organization is created. +type DefaultTeamSpec struct { + Name string + Description string + AccessMode perm.AccessMode + IncludesAllRepositories bool + CanCreateOrgRepo bool + Units map[unit.Type]perm.AccessMode +} + +// DefaultOrgTeams is the list of teams created for every new organization +// (in addition to the mandatory Owners team). Override in tests or via init. +var DefaultOrgTeams = []DefaultTeamSpec{ + { + Name: "Developers", + Description: "Members with write access to code, issues, and pull requests", + AccessMode: perm.AccessModeWrite, + IncludesAllRepositories: true, + Units: map[unit.Type]perm.AccessMode{ + unit.TypeCode: perm.AccessModeWrite, + unit.TypeIssues: perm.AccessModeWrite, + unit.TypePullRequests: perm.AccessModeWrite, + unit.TypeReleases: perm.AccessModeRead, + unit.TypeWiki: perm.AccessModeWrite, + unit.TypeProjects: perm.AccessModeWrite, + }, + }, + { + Name: "Reviewers", + Description: "Members with read access for code review", + AccessMode: perm.AccessModeRead, + IncludesAllRepositories: true, + Units: map[unit.Type]perm.AccessMode{ + unit.TypeCode: perm.AccessModeRead, + unit.TypeIssues: perm.AccessModeRead, + unit.TypePullRequests: perm.AccessModeRead, + unit.TypeReleases: perm.AccessModeRead, + unit.TypeWiki: perm.AccessModeRead, + }, + }, + { + Name: "CI/CD", + Description: "Members with write access to actions and packages", + AccessMode: perm.AccessModeWrite, + IncludesAllRepositories: true, + Units: map[unit.Type]perm.AccessMode{ + unit.TypeCode: perm.AccessModeRead, + unit.TypeActions: perm.AccessModeWrite, + unit.TypePackages: perm.AccessModeWrite, + unit.TypeReleases: perm.AccessModeWrite, + }, + }, +} + func CreateOrganization(ctx context.Context, org *Organization, owner *user_model.User) (err error) { if !owner.CanCreateOrganization() { return ErrUserNotAllowedCreateOrg{} @@ -348,7 +402,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode } org.UseCustomAvatar = true org.MaxRepoCreation = -1 - org.NumTeams = 1 + org.NumTeams = 1 + len(DefaultOrgTeams) org.NumMembers = 1 org.Type = user_model.UserTypeOrganization @@ -413,6 +467,37 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode }); err != nil { return fmt.Errorf("insert team-user relation: %w", err) } + + for _, spec := range DefaultOrgTeams { + dt := &Team{ + OrgID: org.ID, + LowerName: strings.ToLower(spec.Name), + Name: spec.Name, + Description: spec.Description, + AccessMode: spec.AccessMode, + IncludesAllRepositories: spec.IncludesAllRepositories, + CanCreateOrgRepo: spec.CanCreateOrgRepo, + } + if err = db.Insert(ctx, dt); err != nil { + return fmt.Errorf("insert default team %q: %w", spec.Name, err) + } + + dtUnits := make([]TeamUnit, 0, len(spec.Units)) + for tp, am := range spec.Units { + dtUnits = append(dtUnits, TeamUnit{ + OrgID: org.ID, + TeamID: dt.ID, + Type: tp, + AccessMode: am, + }) + } + if len(dtUnits) > 0 { + if err = db.Insert(ctx, &dtUnits); err != nil { + return fmt.Errorf("insert default team %q units: %w", spec.Name, err) + } + } + } + return nil }) } -- 2.52.0 From f53bc895ba1ec9866ff319cde4cc4fecba4c01ae Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:26:23 -0500 Subject: [PATCH 4/9] fix: prevent IDOR in CopyStatusesFromOrg endpoint Add source org visibility + membership check before copying statuses. Non-public source orgs now require the doer to be a member or site admin, preventing unauthorized enumeration of private org statuses. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- routers/api/v1/org/issue_metadata.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/routers/api/v1/org/issue_metadata.go b/routers/api/v1/org/issue_metadata.go index b5bfe8aaa6..e3ee89cc70 100644 --- a/routers/api/v1/org/issue_metadata.go +++ b/routers/api/v1/org/issue_metadata.go @@ -261,6 +261,19 @@ func CopyIssueStatusesFromOrg(ctx *context.APIContext) { ctx.APIErrorNotFound() return } + + if sourceOrg.Visibility != api.VisibleTypePublic && !ctx.Doer.IsAdmin { + isMember, err := org_model.IsOrganizationMember(ctx, sourceOrg.ID, ctx.Doer.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if !isMember { + ctx.APIErrorNotFound() + return + } + } + if err := issues_model.CopyStatusesFromOrg(ctx, sourceOrg.ID, ctx.Org.Organization.ID); err != nil { ctx.APIErrorInternal(err) return -- 2.52.0 From 805c56661517ac09d6e97278dfe9fb707abf1490 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:29:27 -0500 Subject: [PATCH 5/9] fix: remove leaked security scanning routes from cascade-merge branch The security route group belongs to feature/secret-scanning (#692) and was accidentally committed here during parallel agent work. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- routers/api/v1/api.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f87b19f5ac..433a6e7f7f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1271,14 +1271,6 @@ func Routes() *web.Router { m.Delete("", mustNotBeArchived, repo.DeleteCascadeRule) }) }, reqToken(), reqAdmin()) - m.Group("/security", func() { - m.Get("/alerts", repo.ListSecurityAlerts) - m.Get("/alerts/{id}", repo.GetSecurityAlert) - m.Patch("/alerts/{id}", reqToken(), reqAdmin(), repo.UpdateSecurityAlert) - m.Post("/scan", reqToken(), reqAdmin(), repo.TriggerSecurityScan) - m.Get("/config", repo.GetSecurityConfig) - m.Patch("/config", reqToken(), reqAdmin(), repo.UpdateSecurityConfig) - }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) m.Group("/tags", func() { m.Get("", repo.ListTags) m.Get("/*", repo.GetTag) -- 2.52.0 From 7b334f94c0855f6fb8331d4eb58eef836b4b1383 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:14:46 -0500 Subject: [PATCH 6/9] feat: security scanning API endpoints + pre-receive hook blocking (#692) Add REST API for security alerts (list, get, update status, trigger scan) and scanner config (get, update). Wire block_on_push into the pre-receive hook so pushes containing detected secrets are rejected with details. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- CHANGELOG.md | 2 + routers/api/v1/repo/security.go | 214 ++++++++++++++++++++++++++++ routers/private/hook_pre_receive.go | 18 +++ services/security/orchestrator.go | 21 +++ 4 files changed, 255 insertions(+) create mode 100644 routers/api/v1/repo/security.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db6fd594a..485a0f6283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - Wiki full-text search: case-insensitive search across all wiki page titles and content (#550) - Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550) - Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692) +- Security scanning API: REST endpoints for alerts, config, and on-demand scans (GET/PATCH /security/alerts, /security/config, POST /security/scan) (#692) +- Pre-receive hook secret blocking: push rejection when block_on_push enabled and secrets detected in commits (#692) - Metadata API partial updates: PUT /metadata now merges only sent fields instead of replacing all - Wiki revision diff: line-by-line diff view per commit in wiki page history (#667) - Wiki categories: YAML frontmatter `categories:` with category index page (#668) diff --git a/routers/api/v1/repo/security.go b/routers/api/v1/repo/security.go new file mode 100644 index 0000000000..cba4ef0fef --- /dev/null +++ b/routers/api/v1/repo/security.go @@ -0,0 +1,214 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "encoding/json" + "net/http" + "time" + + security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" + security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security" +) + +type apiSecurityAlert struct { + ID int64 `json:"id"` + Scanner string `json:"scanner"` + Severity string `json:"severity"` + Status string `json:"status"` + RuleID string `json:"rule_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + FilePath string `json:"file_path,omitempty"` + LineNumber int `json:"line_number,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` + Fingerprint string `json:"fingerprint"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type apiSecurityConfig struct { + Enabled bool `json:"enabled"` + BlockOnPush bool `json:"block_on_push"` + SecretScanner bool `json:"secret_scanner"` + DependScanner bool `json:"depend_scanner"` +} + +// ListSecurityAlerts returns all security alerts for a repo. +func ListSecurityAlerts(ctx *context.APIContext) { + status := ctx.FormString("status") + repoID := ctx.Repo.Repository.ID + + var alerts []*security_model.SecurityAlert + var err error + + switch status { + case "", "active": + alerts, err = security_model.GetActiveAlerts(ctx, repoID) + default: + alerts, err = security_model.GetAllAlerts(ctx, repoID) + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + + result := make([]*apiSecurityAlert, len(alerts)) + for i, a := range alerts { + result[i] = toAPISecurityAlert(a) + } + ctx.JSON(http.StatusOK, result) +} + +// GetSecurityAlert returns a single security alert. +func GetSecurityAlert(ctx *context.APIContext) { + id := ctx.PathParamInt64("id") + alert, err := security_model.GetAlertByID(ctx, id) + if err != nil { + ctx.APIErrorNotFound() + return + } + if alert.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + ctx.JSON(http.StatusOK, toAPISecurityAlert(alert)) +} + +// UpdateSecurityAlert changes the status of a security alert. +func UpdateSecurityAlert(ctx *context.APIContext) { + id := ctx.PathParamInt64("id") + alert, err := security_model.GetAlertByID(ctx, id) + if err != nil { + ctx.APIErrorNotFound() + return + } + if alert.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + var req struct { + Status string `json:"status"` + } + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + + status := security_model.AlertStatus(req.Status) + if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed { + ctx.APIError(http.StatusUnprocessableEntity, "status must be 'resolved' or 'dismissed'") + return + } + + if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + alert, _ = security_model.GetAlertByID(ctx, id) + ctx.JSON(http.StatusOK, toAPISecurityAlert(alert)) +} + +// TriggerSecurityScan runs all enabled scanners against HEAD. +func TriggerSecurityScan(ctx *context.APIContext) { + commit := ctx.Repo.Commit + if commit == nil { + ctx.APIError(http.StatusBadRequest, "no commits in repository") + return + } + + security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit) + + alerts, err := security_model.GetActiveAlerts(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + result := make([]*apiSecurityAlert, len(alerts)) + for i, a := range alerts { + result[i] = toAPISecurityAlert(a) + } + ctx.JSON(http.StatusOK, result) +} + +// GetSecurityConfig returns the scanner config for a repo. +func GetSecurityConfig(ctx *context.APIContext) { + cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg)) +} + +// UpdateSecurityConfig updates the scanner config for a repo. +func UpdateSecurityConfig(ctx *context.APIContext) { + var req struct { + Enabled *bool `json:"enabled"` + BlockOnPush *bool `json:"block_on_push"` + SecretScanner *bool `json:"secret_scanner"` + DependScanner *bool `json:"depend_scanner"` + } + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + + cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if req.Enabled != nil { + cfg.Enabled = *req.Enabled + } + if req.BlockOnPush != nil { + cfg.BlockOnPush = *req.BlockOnPush + } + if req.SecretScanner != nil { + cfg.SecretScanner = *req.SecretScanner + } + if req.DependScanner != nil { + cfg.DependScanner = *req.DependScanner + } + + if err := security_model.SaveScannerConfig(ctx, cfg); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg)) +} + +func toAPISecurityAlert(a *security_model.SecurityAlert) *apiSecurityAlert { + return &apiSecurityAlert{ + ID: a.ID, + Scanner: string(a.Scanner), + Severity: string(a.Severity), + Status: string(a.Status), + RuleID: a.RuleID, + Title: a.Title, + Description: a.Description, + FilePath: a.FilePath, + LineNumber: a.LineNumber, + CommitSHA: a.CommitSHA, + Fingerprint: a.Fingerprint, + CreatedAt: a.CreatedUnix.AsTime(), + UpdatedAt: a.UpdatedUnix.AsTime(), + } +} + +func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurityConfig { + return &apiSecurityConfig{ + Enabled: cfg.Enabled, + BlockOnPush: cfg.BlockOnPush, + SecretScanner: cfg.SecretScanner, + DependScanner: cfg.DependScanner, + } +} diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 0dc1c9a9a0..e7ccff06e8 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -26,6 +26,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/agit" gitea_context "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull" + security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security" ) type preReceiveContext struct { @@ -151,6 +152,23 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r gitRepo := ctx.Repo.GitRepo objectFormat := ctx.Repo.GetObjectFormat() + if newCommitID != objectFormat.EmptyObjectID().String() { + newCommit, err := gitRepo.GetCommit(newCommitID) + if err == nil { + if findings := security_service.ScanPushForSecrets(ctx, repo.ID, newCommit); len(findings) > 0 { + msg := fmt.Sprintf("Push rejected: %d secret(s) detected in commit %s", len(findings), newCommitID[:12]) + for _, f := range findings { + msg += fmt.Sprintf("\n - %s in %s:%d", f.Title, f.FilePath, f.LineNumber) + } + log.Warn("Secret scan blocked push to %s in %-v: %d findings", branchName, repo, len(findings)) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: msg, + }) + return + } + } + } + defaultBranch := repo.DefaultBranch if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" { defaultBranch = repo.DefaultWikiBranch diff --git a/services/security/orchestrator.go b/services/security/orchestrator.go index f4108d9a89..df68ec5a60 100644 --- a/services/security/orchestrator.go +++ b/services/security/orchestrator.go @@ -12,6 +12,27 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" ) +// ScanPushForSecrets checks a commit for secrets and returns findings. +// Used by the pre-receive hook to block pushes containing secrets. +func ScanPushForSecrets(ctx context.Context, repoID int64, commit *git.Commit) []Finding { + cfg, err := security_model.GetScannerConfig(ctx, repoID) + if err != nil { + log.Error("ScanPushForSecrets: GetScannerConfig: %v", err) + return nil + } + if !cfg.Enabled || !cfg.BlockOnPush || !cfg.SecretScanner { + return nil + } + + scanner := NewSecretScanner() + findings, err := scanner.ScanCommit(commit) + if err != nil { + log.Error("ScanPushForSecrets: ScanCommit: %v", err) + return nil + } + return findings +} + // ScanOnPush runs enabled scanners against a commit pushed to the default branch. // Called from services/repository/push.go on default branch pushes. func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) { -- 2.52.0 From 84df5d79324388524e780e876f393cf3900957a9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:33:29 -0500 Subject: [PATCH 7/9] feat: register security scanning API routes in router Adds /repos/{owner}/{repo}/security/* route group for security alert management, scanning, and configuration endpoints. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- routers/api/v1/api.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5433f11f25..20139b15c7 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1262,6 +1262,14 @@ func Routes() *web.Router { }) m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories) }, reqToken(), reqAdmin()) + m.Group("/security", func() { + m.Get("/alerts", repo.ListSecurityAlerts) + m.Get("/alerts/{id}", repo.GetSecurityAlert) + m.Patch("/alerts/{id}", reqToken(), reqAdmin(), repo.UpdateSecurityAlert) + m.Post("/scan", reqToken(), reqAdmin(), repo.TriggerSecurityScan) + m.Get("/config", repo.GetSecurityConfig) + m.Patch("/config", reqToken(), reqAdmin(), repo.UpdateSecurityConfig) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) m.Group("/tags", func() { m.Get("", repo.ListTags) m.Get("/*", repo.GetTag) -- 2.52.0 From 9a4aa0fafb18a109ea2e30f46d77bc6bfddc5021 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:35:50 -0500 Subject: [PATCH 8/9] fix: log error when pre-receive secret scan cannot read commit Previously, GetCommit failures were silently swallowed, allowing pushes to proceed without scanning. Now logs the error so admins can diagnose issues while still allowing the push. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- routers/private/hook_pre_receive.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index e7ccff06e8..0da5d3a65b 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -154,7 +154,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r if newCommitID != objectFormat.EmptyObjectID().String() { newCommit, err := gitRepo.GetCommit(newCommitID) - if err == nil { + if err != nil { + log.Error("Secret scan: failed to get commit %s in %-v: %v", newCommitID[:12], repo, err) + } else { if findings := security_service.ScanPushForSecrets(ctx, repo.ID, newCommit); len(findings) > 0 { msg := fmt.Sprintf("Push rejected: %d secret(s) detected in commit %s", len(findings), newCommitID[:12]) for _, f := range findings { -- 2.52.0 From cf25eef48048f6dfa34a9a6bc7051b8e04081854 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 03:34:07 -0500 Subject: [PATCH 9/9] fix: distinguish unknown preset from DB errors in ApplyIssueStatusPreset db.ErrNotExist returns 404, other errors return 500 instead of masking all errors as 404. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- routers/api/v1/org/issue_metadata.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/routers/api/v1/org/issue_metadata.go b/routers/api/v1/org/issue_metadata.go index e3ee89cc70..a6aebc72e8 100644 --- a/routers/api/v1/org/issue_metadata.go +++ b/routers/api/v1/org/issue_metadata.go @@ -6,6 +6,7 @@ package org import ( "net/http" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" org_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization" api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" @@ -227,7 +228,11 @@ func ApplyIssueStatusPreset(ctx *context.APIContext) { presetName := ctx.PathParam("preset") if err := issues_model.ApplyStatusPreset(ctx, ctx.Org.Organization.ID, presetName); err != nil { - ctx.APIErrorNotFound() + if db.IsErrNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } return } ctx.Status(http.StatusNoContent) -- 2.52.0