All checks were successful
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 6m49s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m6s
Build and Release / Lint (push) Successful in 7m15s
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 (amd64, linux, linux-latest) (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
Implement critical production readiness features for AI integration: per-request provider config, admin dashboard, workflow inspection, and plugin framework foundation. Per-Request Provider Config: - Add ProviderConfig struct to all AI request types - Update queue to resolve provider/model/API key from cascade (repo > org > system) - Pass resolved config to AI sidecar on every request - Fixes multi-tenant issue where all orgs shared sidecar's hardcoded config Admin AI Dashboard: - Add /admin/ai page with sidecar health status - Display global operation stats (total, 24h, success/fail/escalated counts) - Show operations by tier, top 5 repos, token usage - Recent operations table with repo, operation, status, duration - Add GetGlobalOperationStats model method Workflow Inspection: - Add InspectWorkflow client method and types - Implement workflow-inspect queue handler - Add notifier trigger on workflow file push - Analyzes YAML for syntax errors, security issues, best practices - Returns structured issues with line numbers and suggested fixes Plugin Framework (Phase 5 Foundation): - Add external plugin config loading from app.ini - Define ExternalPlugin interface and manager - Add plugin.proto contract (Initialize, Shutdown, HealthCheck, OnEvent, HandleHTTP) - Implement health monitoring with auto-restart for managed plugins - Add event routing to subscribed plugins - HTTP proxy support for plugin-served routes This completes Tasks 1-4 from the production readiness plan and establishes the foundation for managed plugin lifecycle.
406 lines
11 KiB
Go
406 lines
11 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package ai
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"code.gitcaddy.com/server/v3/models/db"
|
|
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/gitrepo"
|
|
"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.
|
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
|
func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest, providerCfg *ai.ProviderConfig) (*ai.ReviewPullRequestResponse, error) {
|
|
if !IsEnabled() || !setting.AI.EnableCodeReview {
|
|
return nil, errors.New("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)
|
|
}
|
|
|
|
// Open git repo
|
|
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open git repo: %w", err)
|
|
}
|
|
defer gitRepo.Close()
|
|
|
|
// Get the diff
|
|
diff, err := gitdiff.GetDiffForAPI(ctx, gitRepo,
|
|
&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{
|
|
ProviderConfig: providerCfg,
|
|
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.
|
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
|
func TriageIssue(ctx context.Context, issue *issues_model.Issue, providerCfg *ai.ProviderConfig) (*ai.TriageIssueResponse, error) {
|
|
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
|
return nil, errors.New("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, "", db.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{
|
|
ProviderConfig: providerCfg,
|
|
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.
|
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
|
func SuggestLabels(ctx context.Context, issue *issues_model.Issue, providerCfg *ai.ProviderConfig) (*ai.SuggestLabelsResponse, error) {
|
|
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
|
return nil, errors.New("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, "", db.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{
|
|
ProviderConfig: providerCfg,
|
|
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.
|
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
|
func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, code string, startLine, endLine int, question string, providerCfg *ai.ProviderConfig) (*ai.ExplainCodeResponse, error) {
|
|
if !IsEnabled() || !setting.AI.EnableExplainCode {
|
|
return nil, errors.New("AI code explanation is not enabled")
|
|
}
|
|
|
|
req := &ai.ExplainCodeRequest{
|
|
ProviderConfig: providerCfg,
|
|
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.
|
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
|
func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, filePath, code, docType, language, style string, providerCfg *ai.ProviderConfig) (*ai.GenerateDocumentationResponse, error) {
|
|
if !IsEnabled() || !setting.AI.EnableDocGen {
|
|
return nil, errors.New("AI documentation generation is not enabled")
|
|
}
|
|
|
|
req := &ai.GenerateDocumentationRequest{
|
|
ProviderConfig: providerCfg,
|
|
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.
|
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
|
func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, style string, providerCfg *ai.ProviderConfig) (*ai.GenerateCommitMessageResponse, error) {
|
|
if !IsEnabled() || !setting.AI.EnableDocGen {
|
|
return nil, errors.New("AI documentation generation is not enabled")
|
|
}
|
|
|
|
// This would be called from the web editor
|
|
// For now, return a placeholder
|
|
req := &ai.GenerateCommitMessageRequest{
|
|
ProviderConfig: providerCfg,
|
|
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
|
|
}
|
|
|
|
// InspectWorkflow inspects a workflow YAML file using AI.
|
|
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
|
func InspectWorkflow(ctx context.Context, repo *repo_model.Repository, filePath, content string, providerCfg *ai.ProviderConfig) (*ai.InspectWorkflowResponse, error) {
|
|
if !IsEnabled() {
|
|
return nil, errors.New("AI is not enabled")
|
|
}
|
|
|
|
// If content is empty, try to read from the repo
|
|
if content == "" && filePath != "" {
|
|
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open git repo: %w", err)
|
|
}
|
|
defer gitRepo.Close()
|
|
|
|
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get default branch commit: %w", err)
|
|
}
|
|
|
|
blob, err := commit.GetBlobByPath(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read workflow file %s: %w", filePath, err)
|
|
}
|
|
|
|
reader, err := blob.DataAsync()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read blob data: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read workflow content: %w", err)
|
|
}
|
|
content = string(data)
|
|
}
|
|
|
|
req := &ai.InspectWorkflowRequest{
|
|
ProviderConfig: providerCfg,
|
|
RepoID: repo.ID,
|
|
FilePath: filePath,
|
|
Content: content,
|
|
}
|
|
|
|
client := ai.GetClient()
|
|
resp, err := client.InspectWorkflow(ctx, req)
|
|
if err != nil {
|
|
log.Error("AI InspectWorkflow 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 ""
|
|
}
|