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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user