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:
112
models/ai/operation_log.go
Normal file
112
models/ai/operation_log.go
Normal 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
135
models/ai/settings.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user