2
0
Files
gitcaddy-server/services/ai/ai.go
logikonline 61e835358c feat(actions-manager): add AI service integration for code review and issue triage
Integrate GitCaddy AI service with support for code review, issue triage, documentation generation, code explanation, and chat interface. Add AI client module with HTTP communication, configuration settings, API routes (web and REST), service layer, and UI templates for issue sidebar. Include comprehensive configuration options in app.example.ini for enabling/disabling features and service connection settings.
2026-01-19 11:06:39 -05:00

328 lines
8.5 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package ai
import (
"context"
"fmt"
"strings"
issues_model "code.gitcaddy.com/server/v3/models/issues"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/modules/ai"
"code.gitcaddy.com/server/v3/modules/git"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/setting"
"code.gitcaddy.com/server/v3/services/gitdiff"
)
// IsEnabled returns true if AI features are enabled
func IsEnabled() bool {
return ai.IsEnabled()
}
// ReviewPullRequest performs an AI review of a pull request
func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.ReviewPullRequestResponse, error) {
if !IsEnabled() || !setting.AI.EnableCodeReview {
return nil, fmt.Errorf("AI code review is not enabled")
}
if err := pr.LoadBaseRepo(ctx); err != nil {
return nil, fmt.Errorf("failed to load base repo: %w", err)
}
if err := pr.LoadHeadRepo(ctx); err != nil {
return nil, fmt.Errorf("failed to load head repo: %w", err)
}
if err := pr.LoadIssue(ctx); err != nil {
return nil, fmt.Errorf("failed to load issue: %w", err)
}
// Get the diff
diff, err := gitdiff.GetDiff(ctx, pr.BaseRepo,
&gitdiff.DiffOptions{
BeforeCommitID: pr.MergeBase,
AfterCommitID: pr.HeadCommitID,
MaxLines: setting.AI.MaxDiffLines,
MaxLineCharacters: 5000,
MaxFiles: 100,
WhitespaceBehavior: gitdiff.GetWhitespaceFlag(""),
},
)
if err != nil {
return nil, fmt.Errorf("failed to get diff: %w", err)
}
// Convert diff to AI request format
files := make([]ai.FileDiff, 0, len(diff.Files))
for _, file := range diff.Files {
if file.IsBin {
continue // Skip binary files
}
var patch strings.Builder
for _, section := range file.Sections {
for _, line := range section.Lines {
patch.WriteString(line.Content)
patch.WriteString("\n")
}
}
files = append(files, ai.FileDiff{
Path: file.Name,
OldPath: file.OldName,
Status: getFileStatus(file),
Patch: patch.String(),
Language: detectLanguage(file.Name),
})
}
req := &ai.ReviewPullRequestRequest{
RepoID: pr.BaseRepoID,
PullRequestID: pr.ID,
BaseBranch: pr.BaseBranch,
HeadBranch: pr.HeadBranch,
Files: files,
PRTitle: pr.Issue.Title,
PRDescription: pr.Issue.Content,
Options: ai.ReviewOptions{
CheckSecurity: true,
CheckPerformance: true,
CheckStyle: true,
CheckTests: true,
SuggestImprovements: true,
},
}
client := ai.GetClient()
resp, err := client.ReviewPullRequest(ctx, req)
if err != nil {
log.Error("AI ReviewPullRequest failed for PR #%d: %v", pr.Index, err)
return nil, err
}
return resp, nil
}
// TriageIssue performs AI triage on an issue
func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssueResponse, error) {
if !IsEnabled() || !setting.AI.EnableIssueTriage {
return nil, fmt.Errorf("AI issue triage is not enabled")
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, fmt.Errorf("failed to load repo: %w", err)
}
// Get available labels
labels, err := issues_model.GetLabelsByRepoID(ctx, issue.RepoID, "", issues_model.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get labels: %w", err)
}
availableLabels := make([]string, 0, len(labels))
for _, label := range labels {
availableLabels = append(availableLabels, label.Name)
}
// Get existing labels
if err := issue.LoadLabels(ctx); err != nil {
return nil, fmt.Errorf("failed to load existing labels: %w", err)
}
existingLabels := make([]string, 0, len(issue.Labels))
for _, label := range issue.Labels {
existingLabels = append(existingLabels, label.Name)
}
req := &ai.TriageIssueRequest{
RepoID: issue.RepoID,
IssueID: issue.ID,
Title: issue.Title,
Body: issue.Content,
ExistingLabels: existingLabels,
AvailableLabels: availableLabels,
}
client := ai.GetClient()
resp, err := client.TriageIssue(ctx, req)
if err != nil {
log.Error("AI TriageIssue failed for issue #%d: %v", issue.Index, err)
return nil, err
}
return resp, nil
}
// SuggestLabels suggests labels for an issue
func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestLabelsResponse, error) {
if !IsEnabled() || !setting.AI.EnableIssueTriage {
return nil, fmt.Errorf("AI issue triage is not enabled")
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, fmt.Errorf("failed to load repo: %w", err)
}
// Get available labels
labels, err := issues_model.GetLabelsByRepoID(ctx, issue.RepoID, "", issues_model.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get labels: %w", err)
}
availableLabels := make([]string, 0, len(labels))
for _, label := range labels {
availableLabels = append(availableLabels, label.Name)
}
req := &ai.SuggestLabelsRequest{
RepoID: issue.RepoID,
Title: issue.Title,
Body: issue.Content,
AvailableLabels: availableLabels,
}
client := ai.GetClient()
resp, err := client.SuggestLabels(ctx, req)
if err != nil {
log.Error("AI SuggestLabels failed for issue #%d: %v", issue.Index, err)
return nil, err
}
return resp, nil
}
// ExplainCode provides an AI explanation of code
func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, code string, startLine, endLine int, question string) (*ai.ExplainCodeResponse, error) {
if !IsEnabled() || !setting.AI.EnableExplainCode {
return nil, fmt.Errorf("AI code explanation is not enabled")
}
req := &ai.ExplainCodeRequest{
RepoID: repo.ID,
FilePath: filePath,
Code: code,
StartLine: startLine,
EndLine: endLine,
Question: question,
}
client := ai.GetClient()
resp, err := client.ExplainCode(ctx, req)
if err != nil {
log.Error("AI ExplainCode failed: %v", err)
return nil, err
}
return resp, nil
}
// GenerateDocumentation generates documentation for code
func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, filePath, code, docType, language, style string) (*ai.GenerateDocumentationResponse, error) {
if !IsEnabled() || !setting.AI.EnableDocGen {
return nil, fmt.Errorf("AI documentation generation is not enabled")
}
req := &ai.GenerateDocumentationRequest{
RepoID: repo.ID,
FilePath: filePath,
Code: code,
DocType: docType,
Language: language,
Style: style,
}
client := ai.GetClient()
resp, err := client.GenerateDocumentation(ctx, req)
if err != nil {
log.Error("AI GenerateDocumentation failed: %v", err)
return nil, err
}
return resp, nil
}
// GenerateCommitMessage generates a commit message for staged changes
func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, style string) (*ai.GenerateCommitMessageResponse, error) {
if !IsEnabled() || !setting.AI.EnableDocGen {
return nil, fmt.Errorf("AI documentation generation is not enabled")
}
// This would be called from the web editor
// For now, return a placeholder
req := &ai.GenerateCommitMessageRequest{
RepoID: repo.ID,
Files: []ai.FileDiff{},
Style: style,
}
client := ai.GetClient()
resp, err := client.GenerateCommitMessage(ctx, req)
if err != nil {
log.Error("AI GenerateCommitMessage failed: %v", err)
return nil, err
}
return resp, nil
}
// getFileStatus returns the status string for a file diff
func getFileStatus(file *gitdiff.DiffFile) string {
if file.IsDeleted {
return "deleted"
}
if file.IsCreated {
return "added"
}
if file.IsRenamed {
return "renamed"
}
return "modified"
}
// detectLanguage detects the programming language from file extension
func detectLanguage(filename string) string {
ext := strings.ToLower(filename)
if idx := strings.LastIndex(ext, "."); idx >= 0 {
ext = ext[idx+1:]
}
langMap := map[string]string{
"go": "go",
"py": "python",
"js": "javascript",
"ts": "typescript",
"jsx": "javascript",
"tsx": "typescript",
"java": "java",
"cs": "csharp",
"cpp": "cpp",
"c": "c",
"h": "c",
"hpp": "cpp",
"rs": "rust",
"rb": "ruby",
"php": "php",
"swift": "swift",
"kt": "kotlin",
"scala": "scala",
"r": "r",
"sql": "sql",
"sh": "bash",
"bash": "bash",
"yml": "yaml",
"yaml": "yaml",
"json": "json",
"xml": "xml",
"html": "html",
"css": "css",
"scss": "scss",
"less": "less",
"md": "markdown",
}
if lang, ok := langMap[ext]; ok {
return lang
}
return ""
}