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 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.
273 lines
7.5 KiB
Go
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
|
|
}
|