Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98301bc92b | |||
| c618ec9f87 | |||
| 5a25068d81 | |||
| 57894e25fd | |||
| 2857a1f6a1 | |||
| b9c04e51b4 | |||
| cf25eef480 | |||
| 9a4aa0fafb | |||
| 84df5d7932 | |||
| 7b334f94c0 | |||
| 805c566615 | |||
| f53bc895ba | |||
| e99658ddc0 | |||
| f627219ca8 | |||
| df9305758f |
@@ -3,6 +3,10 @@
|
||||
## [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)
|
||||
- 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)
|
||||
@@ -10,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)
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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))
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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"`
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
@@ -1782,6 +1799,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())
|
||||
|
||||
@@ -6,7 +6,9 @@ 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"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
@@ -162,3 +164,124 @@ 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 {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
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 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
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,25 @@ 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 {
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -62,6 +62,28 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5>{{ctx.Locale.Tr "org.settings.issue_status_presets"}}</h5>
|
||||
<p class="text grey">{{ctx.Locale.Tr "org.settings.issue_status_presets_desc"}}</p>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/issue-statuses/apply-preset">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="inline fields">
|
||||
<div class="field">
|
||||
<select name="preset" class="ui dropdown">
|
||||
{{range .StatusPresets}}
|
||||
<option value="{{.Name}}">{{.Description}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="ui primary button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "org.settings.issue_status_preset_confirm"}}')">
|
||||
{{svg "octicon-checklist" 14}} {{ctx.Locale.Tr "org.settings.issue_status_preset_apply"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<h5>{{ctx.Locale.Tr "org.settings.issue_status_add"}}</h5>
|
||||
<form class="ui form" method="post" action="{{.OrgLink}}/settings/issue-statuses">
|
||||
{{.CsrfTokenHtml}}
|
||||
|
||||
Reference in New Issue
Block a user