From b94f41b5971d6eb54b875023677728b63a4f9adf Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:00:17 -0500 Subject: [PATCH 1/4] 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 965abb54b803bc0fdd0017a877dd883fd8639724 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:03:15 -0500 Subject: [PATCH 2/4] 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 21568974ec..1b612bdaa7 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) - 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) 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 ecc1f20162d8971d446e8a19d0a68245ef45d464 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:04:40 -0500 Subject: [PATCH 3/4] =?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 1b612bdaa7..e7da045139 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) - 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) - Auto-create default teams on org creation: Developers (write), Reviewers (read), CI/CD (actions+packages) (#513) 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 3a37c0d115..9bd32f29b6 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 240fe1ebe583af4b341182b1b5de63f93cb1cff0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:14:46 -0500 Subject: [PATCH 4/4] 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 e7da045139..69bca97950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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