2
0
Files
logikonline f42c6c39f9
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
feat(ai-service): complete ai production readiness tasks
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.
2026-02-13 01:16:58 -05:00

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 ""
}