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.
328 lines
8.5 KiB
Go
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 ""
|
|
}
|