diff --git a/models/ai/operation_log.go b/models/ai/operation_log.go new file mode 100644 index 0000000000..ff1ed80075 --- /dev/null +++ b/models/ai/operation_log.go @@ -0,0 +1,112 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package ai + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" + "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/builder" +) + +func init() { + db.RegisterModel(new(OperationLog)) +} + +// OperationLog records every AI operation for auditing +type OperationLog struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + Operation string `xorm:"VARCHAR(50) NOT NULL"` // "code-review", "issue-response", etc. + Tier int `xorm:"NOT NULL"` // 1 or 2 + TriggerEvent string `xorm:"VARCHAR(100) NOT NULL"` + TriggerUserID int64 `xorm:"INDEX"` + TargetID int64 `xorm:"INDEX"` // issue/PR ID + TargetType string `xorm:"VARCHAR(20)"` // "issue", "pull", "commit" + Provider string `xorm:"VARCHAR(20)"` + Model string `xorm:"VARCHAR(100)"` + InputTokens int `xorm:"DEFAULT 0"` + OutputTokens int `xorm:"DEFAULT 0"` + Status string `xorm:"VARCHAR(20) NOT NULL"` // "success", "failed", "escalated", "pending" + ResultCommentID int64 `xorm:"DEFAULT 0"` + ActionRunID int64 `xorm:"DEFAULT 0"` // for Tier 2 + ErrorMessage string `xorm:"TEXT"` + DurationMs int64 `xorm:"DEFAULT 0"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` +} + +// TableName returns the table name for OperationLog +func (OperationLog) TableName() string { + return "ai_operation_log" +} + +// OperationStatus constants +const ( + OperationStatusPending = "pending" + OperationStatusSuccess = "success" + OperationStatusFailed = "failed" + OperationStatusEscalated = "escalated" +) + +// InsertOperationLog creates a new operation log entry +func InsertOperationLog(ctx context.Context, log *OperationLog) error { + return db.Insert(ctx, log) +} + +// UpdateOperationLog updates an existing operation log entry +func UpdateOperationLog(ctx context.Context, log *OperationLog) error { + _, err := db.GetEngine(ctx).ID(log.ID).AllCols().Update(log) + return err +} + +// GetOperationLog returns a single operation log entry by ID +func GetOperationLog(ctx context.Context, id int64) (*OperationLog, error) { + log := &OperationLog{} + has, err := db.GetEngine(ctx).ID(id).Get(log) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return log, nil +} + +// FindOperationLogsOptions represents options for finding operation logs +type FindOperationLogsOptions struct { + db.ListOptions + RepoID int64 + Operation string + Status string + Tier int +} + +func (opts FindOperationLogsOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } + if opts.Operation != "" { + cond = cond.And(builder.Eq{"operation": opts.Operation}) + } + if opts.Status != "" { + cond = cond.And(builder.Eq{"status": opts.Status}) + } + if opts.Tier > 0 { + cond = cond.And(builder.Eq{"tier": opts.Tier}) + } + return cond +} + +func (opts FindOperationLogsOptions) ToOrders() string { + return "created_unix DESC" +} + +// CountRecentOperations counts operations in the last hour for rate limiting +func CountRecentOperations(ctx context.Context, repoID int64) (int64, error) { + oneHourAgo := timeutil.TimeStampNow() - 3600 + return db.GetEngine(ctx).Where("repo_id = ? AND created_unix > ?", repoID, oneHourAgo).Count(new(OperationLog)) +} diff --git a/models/ai/settings.go b/models/ai/settings.go new file mode 100644 index 0000000000..da3a313e81 --- /dev/null +++ b/models/ai/settings.go @@ -0,0 +1,135 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package ai + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" + secret_module "code.gitcaddy.com/server/v3/modules/secret" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/timeutil" +) + +func init() { + db.RegisterModel(new(OrgAISettings)) +} + +// OrgAISettings stores AI configuration per organization +type OrgAISettings struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"UNIQUE NOT NULL INDEX"` + Provider string `xorm:"NOT NULL DEFAULT ''"` + Model string `xorm:"NOT NULL DEFAULT ''"` + APIKeyEncrypted string `xorm:"TEXT"` + MaxOpsPerHour int `xorm:"NOT NULL DEFAULT 0"` + AllowedOps string `xorm:"TEXT"` // JSON array of allowed operation names + AgentModeAllowed bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX UPDATED"` +} + +// TableName returns the table name for OrgAISettings +func (OrgAISettings) TableName() string { + return "org_ai_settings" +} + +// SetAPIKey encrypts and stores the API key +func (s *OrgAISettings) SetAPIKey(key string) error { + if key == "" { + s.APIKeyEncrypted = "" + return nil + } + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, key) + if err != nil { + return err + } + s.APIKeyEncrypted = encrypted + return nil +} + +// GetAPIKey decrypts and returns the API key +func (s *OrgAISettings) GetAPIKey() (string, error) { + if s.APIKeyEncrypted == "" { + return "", nil + } + return secret_module.DecryptSecret(setting.SecretKey, s.APIKeyEncrypted) +} + +// GetOrgAISettings returns the AI settings for an organization +func GetOrgAISettings(ctx context.Context, orgID int64) (*OrgAISettings, error) { + settings := &OrgAISettings{OrgID: orgID} + has, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Get(settings) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return settings, nil +} + +// CreateOrUpdateOrgAISettings creates or updates AI settings for an organization +func CreateOrUpdateOrgAISettings(ctx context.Context, settings *OrgAISettings) error { + existing := &OrgAISettings{} + has, err := db.GetEngine(ctx).Where("org_id = ?", settings.OrgID).Get(existing) + if err != nil { + return err + } + if has { + settings.ID = existing.ID + _, err = db.GetEngine(ctx).ID(existing.ID).AllCols().Update(settings) + return err + } + return db.Insert(ctx, settings) +} + +// ResolveProvider resolves the AI provider using the cascade: repo → org → system +func ResolveProvider(ctx context.Context, orgID int64, repoProvider string) string { + if repoProvider != "" { + return repoProvider + } + if orgID > 0 { + if orgSettings, err := GetOrgAISettings(ctx, orgID); err == nil && orgSettings != nil && orgSettings.Provider != "" { + return orgSettings.Provider + } + } + return setting.AI.DefaultProvider +} + +// ResolveModel resolves the AI model using the cascade: repo → org → system +func ResolveModel(ctx context.Context, orgID int64, repoModel string) string { + if repoModel != "" { + return repoModel + } + if orgID > 0 { + if orgSettings, err := GetOrgAISettings(ctx, orgID); err == nil && orgSettings != nil && orgSettings.Model != "" { + return orgSettings.Model + } + } + return setting.AI.DefaultModel +} + +// ResolveAPIKey resolves the API key using the cascade: org → system +func ResolveAPIKey(ctx context.Context, orgID int64, provider string) string { + // Try org-level key first + if orgID > 0 { + if orgSettings, err := GetOrgAISettings(ctx, orgID); err == nil && orgSettings != nil { + if key, err := orgSettings.GetAPIKey(); err == nil && key != "" { + return key + } + } + } + // Fall back to system-level key + switch provider { + case "claude": + return setting.AI.ClaudeAPIKey + case "openai": + return setting.AI.OpenAIAPIKey + case "gemini": + return setting.AI.GeminiAPIKey + default: + return "" + } +} diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index e0f4315a6e..480b05a29c 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -219,6 +219,62 @@ func (cfg *ActionsConfig) ToDB() ([]byte, error) { return json.Marshal(cfg) } +// AIConfig describes AI integration config +type AIConfig struct { + // Tier 1: Light AI operations + AutoRespondToIssues bool `json:"auto_respond_issues"` + AutoReviewPRs bool `json:"auto_review_prs"` + AutoInspectWorkflows bool `json:"auto_inspect_workflows"` + AutoTriageIssues bool `json:"auto_triage_issues"` + + // Tier 2: Advanced agent operations + AgentModeEnabled bool `json:"agent_mode_enabled"` + AgentTriggerLabels []string `json:"agent_trigger_labels"` + AgentMaxRunMinutes int `json:"agent_max_run_minutes"` + + // Escalation + EscalateToStaff bool `json:"escalate_to_staff"` + EscalationLabel string `json:"escalation_label"` + EscalationAssignTeam string `json:"escalation_assign_team"` + + // Provider overrides (empty = inherit from org → system) + PreferredProvider string `json:"preferred_provider"` + PreferredModel string `json:"preferred_model"` + + // Custom instructions + SystemInstructions string `json:"system_instructions"` + ReviewInstructions string `json:"review_instructions"` + IssueInstructions string `json:"issue_instructions"` +} + +// FromDB fills up an AIConfig from serialized format. +func (cfg *AIConfig) FromDB(bs []byte) error { + return json.UnmarshalHandleDoubleEncode(bs, &cfg) +} + +// ToDB exports an AIConfig to a serialized format. +func (cfg *AIConfig) ToDB() ([]byte, error) { + return json.Marshal(cfg) +} + +// IsOperationEnabled returns whether a given AI operation is enabled +func (cfg *AIConfig) IsOperationEnabled(op string) bool { + switch op { + case "issue-response": + return cfg.AutoRespondToIssues + case "issue-triage": + return cfg.AutoTriageIssues + case "code-review": + return cfg.AutoReviewPRs + case "workflow-inspect": + return cfg.AutoInspectWorkflows + case "agent-fix": + return cfg.AgentModeEnabled + default: + return false + } +} + // ProjectsMode represents the projects enabled for a repository type ProjectsMode string @@ -281,6 +337,8 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) { r.Config = new(IssuesConfig) case unit.TypeActions: r.Config = new(ActionsConfig) + case unit.TypeAI: + r.Config = new(AIConfig) case unit.TypeProjects: r.Config = new(ProjectsConfig) case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypePackages: @@ -336,6 +394,11 @@ func (r *RepoUnit) ProjectsConfig() *ProjectsConfig { return r.Config.(*ProjectsConfig) } +// AIConfig returns config for unit.TypeAI +func (r *RepoUnit) AIConfig() *AIConfig { + return r.Config.(*AIConfig) +} + func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err error) { var tmpUnits []*RepoUnit if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&tmpUnits); err != nil { diff --git a/models/unit/unit.go b/models/unit/unit.go index aa49b03f8a..d668b8bf89 100644 --- a/models/unit/unit.go +++ b/models/unit/unit.go @@ -33,6 +33,7 @@ const ( TypeProjects // 8 Projects TypePackages // 9 Packages TypeActions // 10 Actions + TypeAI // 11 AI // FIXME: TEAM-UNIT-PERMISSION: the team unit "admin" permission's design is not right, when a new unit is added in the future, // admin team won't inherit the correct admin permission for the new unit, need to have a complete fix before adding any new unit. @@ -65,6 +66,7 @@ var ( TypeProjects, TypePackages, TypeActions, + TypeAI, } // DefaultRepoUnits contains the default unit types @@ -110,6 +112,7 @@ var ( NotAllowedDefaultRepoUnits = []Type{ TypeExternalWiki, TypeExternalTracker, + TypeAI, } disabledRepoUnitsAtomic atomic.Pointer[[]Type] // the units that have been globally disabled @@ -328,6 +331,15 @@ var ( perm.AccessModeOwner, } + UnitAI = Unit{ + TypeAI, + "repo.ai", + "/ai", + "repo.ai.desc", + 8, + perm.AccessModeOwner, + } + // Units contains all the units Units = map[Type]Unit{ TypeCode: UnitCode, @@ -340,6 +352,7 @@ var ( TypeProjects: UnitProjects, TypePackages: UnitPackages, TypeActions: UnitActions, + TypeAI: UnitAI, } ) diff --git a/modules/setting/ai.go b/modules/setting/ai.go index 54328f460a..c8cf7dfe48 100644 --- a/modules/setting/ai.go +++ b/modules/setting/ai.go @@ -11,31 +11,62 @@ import ( // AI settings for the GitCaddy AI service integration var AI = struct { - Enabled bool - ServiceURL string - ServiceToken string - Timeout time.Duration - MaxRetries int + Enabled bool + ServiceURL string + ServiceToken string + Timeout time.Duration + MaxRetries int + + // Provider/model defaults (fallback when org doesn't configure) + DefaultProvider string + DefaultModel string + + // System API keys (used when org/repo doesn't provide their own) + ClaudeAPIKey string + OpenAIAPIKey string + GeminiAPIKey string + + // Rate limiting + MaxOperationsPerHour int + MaxTokensPerOperation int + + // Feature gates (admin controls what's available) EnableCodeReview bool EnableIssueTriage bool EnableDocGen bool EnableExplainCode bool EnableChat bool - MaxFileSizeKB int64 - MaxDiffLines int + AllowAutoRespond bool + AllowAutoReview bool + AllowAgentMode bool + + // Content limits + MaxFileSizeKB int64 + MaxDiffLines int + + // Bot user + BotUserName string }{ - Enabled: false, - ServiceURL: "localhost:50051", - ServiceToken: "", - Timeout: 30 * time.Second, - MaxRetries: 3, - EnableCodeReview: true, - EnableIssueTriage: true, - EnableDocGen: true, - EnableExplainCode: true, - EnableChat: true, - MaxFileSizeKB: 500, - MaxDiffLines: 5000, + Enabled: false, + ServiceURL: "localhost:50051", + ServiceToken: "", + Timeout: 30 * time.Second, + MaxRetries: 3, + DefaultProvider: "claude", + DefaultModel: "claude-sonnet-4-20250514", + MaxOperationsPerHour: 100, + MaxTokensPerOperation: 8192, + EnableCodeReview: true, + EnableIssueTriage: true, + EnableDocGen: true, + EnableExplainCode: true, + EnableChat: true, + AllowAutoRespond: true, + AllowAutoReview: true, + AllowAgentMode: false, + MaxFileSizeKB: 500, + MaxDiffLines: 5000, + BotUserName: "gitcaddy-ai", } func loadAIFrom(rootCfg ConfigProvider) { @@ -45,14 +76,46 @@ func loadAIFrom(rootCfg ConfigProvider) { AI.ServiceToken = sec.Key("SERVICE_TOKEN").MustString("") AI.Timeout = sec.Key("TIMEOUT").MustDuration(30 * time.Second) AI.MaxRetries = sec.Key("MAX_RETRIES").MustInt(3) + + // Provider/model + AI.DefaultProvider = sec.Key("DEFAULT_PROVIDER").MustString("claude") + AI.DefaultModel = sec.Key("DEFAULT_MODEL").MustString("claude-sonnet-4-20250514") + + // Validate provider + switch AI.DefaultProvider { + case "claude", "openai", "gemini": + // valid + default: + log.Error("[ai] DEFAULT_PROVIDER %q is not supported, falling back to claude", AI.DefaultProvider) + AI.DefaultProvider = "claude" + } + + // System API keys + AI.ClaudeAPIKey = sec.Key("CLAUDE_API_KEY").MustString("") + AI.OpenAIAPIKey = sec.Key("OPENAI_API_KEY").MustString("") + AI.GeminiAPIKey = sec.Key("GEMINI_API_KEY").MustString("") + + // Rate limiting + AI.MaxOperationsPerHour = sec.Key("MAX_OPERATIONS_PER_HOUR").MustInt(100) + AI.MaxTokensPerOperation = sec.Key("MAX_TOKENS_PER_OPERATION").MustInt(8192) + + // Feature gates AI.EnableCodeReview = sec.Key("ENABLE_CODE_REVIEW").MustBool(true) AI.EnableIssueTriage = sec.Key("ENABLE_ISSUE_TRIAGE").MustBool(true) AI.EnableDocGen = sec.Key("ENABLE_DOC_GEN").MustBool(true) AI.EnableExplainCode = sec.Key("ENABLE_EXPLAIN_CODE").MustBool(true) AI.EnableChat = sec.Key("ENABLE_CHAT").MustBool(true) + AI.AllowAutoRespond = sec.Key("ALLOW_AUTO_RESPOND").MustBool(true) + AI.AllowAutoReview = sec.Key("ALLOW_AUTO_REVIEW").MustBool(true) + AI.AllowAgentMode = sec.Key("ALLOW_AGENT_MODE").MustBool(false) + + // Content limits AI.MaxFileSizeKB = sec.Key("MAX_FILE_SIZE_KB").MustInt64(500) AI.MaxDiffLines = sec.Key("MAX_DIFF_LINES").MustInt(5000) + // Bot user + AI.BotUserName = sec.Key("BOT_USER_NAME").MustString("gitcaddy-ai") + if AI.Enabled && AI.ServiceURL == "" { log.Error("AI is enabled but SERVICE_URL is not configured") AI.Enabled = false