2
0
Files
gitcaddy-server/services/ai/notifier.go
logikonline 14338d8fd4
All checks were successful
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 7m11s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m21s
Build and Release / Lint (push) Successful in 7m32s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
refactor(ai): consolidate ai operation types and reduce duplication
Refactor AI service layer to reduce code duplication and improve consistency.

Changes:
- Rename AIOperationRequest to OperationRequest for consistency
- Extract shared logic for issue-targeted operations (respond, triage) into triggerIssueAIOp helper
- Standardize field alignment in struct definitions
- Remove redundant error handling patterns

This reduces the API operations file by ~40 lines while maintaining identical functionality.
2026-02-12 00:55:52 -05:00

273 lines
7.5 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package ai
import (
"context"
"slices"
issues_model "code.gitcaddy.com/server/v3/models/issues"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/models/unit"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/setting"
notify_service "code.gitcaddy.com/server/v3/services/notify"
)
type aiNotifier struct {
notify_service.NullNotifier
}
var _ notify_service.Notifier = &aiNotifier{}
// NewNotifier creates a new AI notifier
func NewNotifier() notify_service.Notifier {
return &aiNotifier{}
}
// isAIUser returns true if the doer is the AI bot user (loop prevention)
func isAIUser(doer *user_model.User) bool {
return doer != nil && doer.ID == user_model.AIUserID
}
// getAIConfig loads the AI config for a repo, returns nil if AI unit is not enabled
func getAIConfig(ctx context.Context, repo *repo_model.Repository) *repo_model.AIConfig {
if !setting.AI.Enabled {
return nil
}
aiUnit, err := repo.GetUnit(ctx, unit.TypeAI)
if err != nil {
return nil // AI unit not enabled for this repo
}
return aiUnit.AIConfig()
}
// NewIssue handles new issue events — triggers auto-respond and auto-triage
func (n *aiNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) {
if err := issue.LoadPoster(ctx); err != nil {
log.Error("AI notifier: issue.LoadPoster: %v", err)
return
}
if isAIUser(issue.Poster) {
return
}
if err := issue.LoadRepo(ctx); err != nil {
log.Error("AI notifier: issue.LoadRepo: %v", err)
return
}
aiCfg := getAIConfig(ctx, issue.Repo)
if aiCfg == nil {
return
}
if aiCfg.AutoRespondToIssues && setting.AI.AllowAutoRespond {
if err := EnqueueOperation(&OperationRequest{
RepoID: issue.RepoID,
Operation: "issue-response",
Tier: 1,
TriggerEvent: "issue.created",
TriggerUserID: issue.PosterID,
TargetID: issue.ID,
TargetType: "issue",
}); err != nil {
log.Error("AI notifier: failed to enqueue issue-response for issue #%d: %v", issue.Index, err)
}
}
if aiCfg.AutoTriageIssues && setting.AI.EnableIssueTriage {
if err := EnqueueOperation(&OperationRequest{
RepoID: issue.RepoID,
Operation: "issue-triage",
Tier: 1,
TriggerEvent: "issue.created",
TriggerUserID: issue.PosterID,
TargetID: issue.ID,
TargetType: "issue",
}); err != nil {
log.Error("AI notifier: failed to enqueue issue-triage for issue #%d: %v", issue.Index, err)
}
}
}
// CreateIssueComment handles new comments — triggers AI response if the bot is mentioned
func (n *aiNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User,
) {
if isAIUser(doer) {
return
}
aiCfg := getAIConfig(ctx, repo)
if aiCfg == nil {
return
}
// Only respond to comments that mention the AI bot or explicitly ask a question
if !aiCfg.AutoRespondToIssues || !setting.AI.AllowAutoRespond {
return
}
// Check if the comment mentions the AI bot
if !isBotMentioned(comment.Content) {
return
}
if err := EnqueueOperation(&OperationRequest{
RepoID: repo.ID,
Operation: "issue-response",
Tier: 1,
TriggerEvent: "issue_comment.created",
TriggerUserID: doer.ID,
TargetID: issue.ID,
TargetType: "issue",
}); err != nil {
log.Error("AI notifier: failed to enqueue issue-response for comment on issue #%d: %v", issue.Index, err)
}
}
// NewPullRequest handles new PR events — triggers auto-review
func (n *aiNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, _ []*user_model.User) {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("AI notifier: pr.LoadIssue: %v", err)
return
}
if err := pr.Issue.LoadPoster(ctx); err != nil {
log.Error("AI notifier: pr.Issue.LoadPoster: %v", err)
return
}
if isAIUser(pr.Issue.Poster) {
return
}
if err := pr.Issue.LoadRepo(ctx); err != nil {
log.Error("AI notifier: pr.Issue.LoadRepo: %v", err)
return
}
aiCfg := getAIConfig(ctx, pr.Issue.Repo)
if aiCfg == nil {
return
}
if aiCfg.AutoReviewPRs && setting.AI.AllowAutoReview {
if err := EnqueueOperation(&OperationRequest{
RepoID: pr.Issue.RepoID,
Operation: "code-review",
Tier: 1,
TriggerEvent: "pull_request.opened",
TriggerUserID: pr.Issue.PosterID,
TargetID: pr.Issue.ID,
TargetType: "pull",
}); err != nil {
log.Error("AI notifier: failed to enqueue code-review for PR #%d: %v", pr.Issue.Index, err)
}
}
}
// PullRequestSynchronized handles PR push events — triggers re-review
func (n *aiNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if isAIUser(doer) {
return
}
if err := pr.LoadIssue(ctx); err != nil {
log.Error("AI notifier: pr.LoadIssue: %v", err)
return
}
if err := pr.Issue.LoadRepo(ctx); err != nil {
log.Error("AI notifier: pr.Issue.LoadRepo: %v", err)
return
}
aiCfg := getAIConfig(ctx, pr.Issue.Repo)
if aiCfg == nil {
return
}
if aiCfg.AutoReviewPRs && setting.AI.AllowAutoReview {
if err := EnqueueOperation(&OperationRequest{
RepoID: pr.Issue.RepoID,
Operation: "code-review",
Tier: 1,
TriggerEvent: "pull_request.synchronized",
TriggerUserID: doer.ID,
TargetID: pr.Issue.ID,
TargetType: "pull",
}); err != nil {
log.Error("AI notifier: failed to enqueue code-review for PR #%d sync: %v", pr.Issue.Index, err)
}
}
}
// IssueChangeLabels handles label changes — triggers Tier 2 agent fix if trigger label is added
func (n *aiNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue,
addedLabels, removedLabels []*issues_model.Label,
) {
if isAIUser(doer) {
return
}
if err := issue.LoadRepo(ctx); err != nil {
log.Error("AI notifier: issue.LoadRepo: %v", err)
return
}
aiCfg := getAIConfig(ctx, issue.Repo)
if aiCfg == nil {
return
}
if !aiCfg.AgentModeEnabled || !setting.AI.AllowAgentMode {
return
}
// Check if any added label matches the agent trigger labels
for _, label := range addedLabels {
if slices.Contains(aiCfg.AgentTriggerLabels, label.Name) {
if err := EnqueueOperation(&OperationRequest{
RepoID: issue.RepoID,
Operation: "agent-fix",
Tier: 2,
TriggerEvent: "issue.label_added",
TriggerUserID: doer.ID,
TargetID: issue.ID,
TargetType: "issue",
}); err != nil {
log.Error("AI notifier: failed to enqueue agent-fix for issue #%d: %v", issue.Index, err)
}
return // Only trigger once per label change event
}
}
}
// isBotMentioned checks if the AI bot user is mentioned in text
func isBotMentioned(content string) bool {
botName := setting.AI.BotUserName
// Simple check for @mention
return len(content) > 0 && len(botName) > 0 &&
containsMention(content, botName)
}
// containsMention checks if @username appears in text
func containsMention(text, username string) bool {
target := "@" + username
for i := 0; i <= len(text)-len(target); i++ {
if text[i:i+len(target)] == target {
// Check that it's a word boundary (not part of a longer word)
if i+len(target) < len(text) {
next := text[i+len(target)]
if next != ' ' && next != '\n' && next != '\t' && next != ',' && next != '.' && next != '!' && next != '?' {
continue
}
}
return true
}
}
return false
}