2
0

feat(ai): add ai operation logging and org settings models

Add database models and infrastructure for AI operation tracking and organization-level AI configuration.

OperationLog model tracks all AI operations for auditing, including:
- Operation type, tier, and trigger event
- Token usage (input/output)
- Status tracking (pending, success, failed, escalated)
- Performance metrics (duration)
- Rate limiting support via CountRecentOperations

OrgAISettings model stores per-organization AI configuration:
- Provider and model selection
- Encrypted API key storage
- Rate limits (max operations per hour)
- Allowed operations whitelist
- Agent mode permissions

Also adds AI unit type to repository units for enabling/disabling AI features per repo.
This commit is contained in:
2026-02-11 23:46:57 -05:00
parent 7102167351
commit 26793bf898
5 changed files with 405 additions and 19 deletions

112
models/ai/operation_log.go Normal file
View File

@@ -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))
}

135
models/ai/settings.go Normal file
View File

@@ -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 ""
}
}

View File

@@ -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 {

View File

@@ -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,
}
)

View File

@@ -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