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