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.
This commit is contained in:
@@ -1699,6 +1699,48 @@ LEVEL = Info
|
||||
;; auto = link directly with the account
|
||||
;ACCOUNT_LINKING = login
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;[ai]
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;
|
||||
;; Enable AI-powered features (requires GitCaddy AI service)
|
||||
;ENABLED = false
|
||||
;;
|
||||
;; URL of the GitCaddy AI service (host:port)
|
||||
;SERVICE_URL = localhost:50051
|
||||
;;
|
||||
;; Authentication token for the AI service
|
||||
;SERVICE_TOKEN =
|
||||
;;
|
||||
;; Request timeout for AI operations
|
||||
;TIMEOUT = 30s
|
||||
;;
|
||||
;; Maximum retry attempts for failed requests
|
||||
;MAX_RETRIES = 3
|
||||
;;
|
||||
;; Enable AI-powered code review for pull requests
|
||||
;ENABLE_CODE_REVIEW = true
|
||||
;;
|
||||
;; Enable AI-powered issue triage
|
||||
;ENABLE_ISSUE_TRIAGE = true
|
||||
;;
|
||||
;; Enable AI documentation generation
|
||||
;ENABLE_DOC_GEN = true
|
||||
;;
|
||||
;; Enable AI code explanation
|
||||
;ENABLE_EXPLAIN_CODE = true
|
||||
;;
|
||||
;; Enable AI chat interface
|
||||
;ENABLE_CHAT = true
|
||||
;;
|
||||
;; Maximum file size in KB for AI analysis
|
||||
;MAX_FILE_SIZE_KB = 500
|
||||
;;
|
||||
;; Maximum number of diff lines to send to AI
|
||||
;MAX_DIFF_LINES = 5000
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;[webhook]
|
||||
|
||||
199
modules/ai/client.go
Normal file
199
modules/ai/client.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
)
|
||||
|
||||
// Client is the AI service client
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
token string
|
||||
}
|
||||
|
||||
var defaultClient *Client
|
||||
|
||||
// GetClient returns the default AI client
|
||||
func GetClient() *Client {
|
||||
if defaultClient == nil {
|
||||
defaultClient = NewClient(setting.AI.ServiceURL, setting.AI.ServiceToken, setting.AI.Timeout)
|
||||
}
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// NewClient creates a new AI service client
|
||||
func NewClient(baseURL, token string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns true if AI service is enabled
|
||||
func IsEnabled() bool {
|
||||
return setting.AI.Enabled
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request to the AI service
|
||||
func (c *Client) doRequest(ctx context.Context, method, endpoint string, body any, result any) error {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewBuffer(jsonBody)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/api/v1%s", c.baseURL, endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("AI service error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReviewPullRequest requests an AI review of a pull request
|
||||
func (c *Client) ReviewPullRequest(ctx context.Context, req *ReviewPullRequestRequest) (*ReviewPullRequestResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableCodeReview {
|
||||
return nil, fmt.Errorf("AI code review is not enabled")
|
||||
}
|
||||
|
||||
var resp ReviewPullRequestResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/review/pull-request", req, &resp); err != nil {
|
||||
log.Error("AI ReviewPullRequest failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// TriageIssue requests AI triage for an issue
|
||||
func (c *Client) TriageIssue(ctx context.Context, req *TriageIssueRequest) (*TriageIssueResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
||||
return nil, fmt.Errorf("AI issue triage is not enabled")
|
||||
}
|
||||
|
||||
var resp TriageIssueResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/issues/triage", req, &resp); err != nil {
|
||||
log.Error("AI TriageIssue failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SuggestLabels requests AI label suggestions
|
||||
func (c *Client) SuggestLabels(ctx context.Context, req *SuggestLabelsRequest) (*SuggestLabelsResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
||||
return nil, fmt.Errorf("AI issue triage is not enabled")
|
||||
}
|
||||
|
||||
var resp SuggestLabelsResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/issues/suggest-labels", req, &resp); err != nil {
|
||||
log.Error("AI SuggestLabels failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ExplainCode requests an AI explanation of code
|
||||
func (c *Client) ExplainCode(ctx context.Context, req *ExplainCodeRequest) (*ExplainCodeResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableExplainCode {
|
||||
return nil, fmt.Errorf("AI code explanation is not enabled")
|
||||
}
|
||||
|
||||
var resp ExplainCodeResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/code/explain", req, &resp); err != nil {
|
||||
log.Error("AI ExplainCode failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GenerateDocumentation requests AI-generated documentation
|
||||
func (c *Client) GenerateDocumentation(ctx context.Context, req *GenerateDocumentationRequest) (*GenerateDocumentationResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableDocGen {
|
||||
return nil, fmt.Errorf("AI documentation generation is not enabled")
|
||||
}
|
||||
|
||||
var resp GenerateDocumentationResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/docs/generate", req, &resp); err != nil {
|
||||
log.Error("AI GenerateDocumentation failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GenerateCommitMessage requests an AI-generated commit message
|
||||
func (c *Client) GenerateCommitMessage(ctx context.Context, req *GenerateCommitMessageRequest) (*GenerateCommitMessageResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableDocGen {
|
||||
return nil, fmt.Errorf("AI documentation generation is not enabled")
|
||||
}
|
||||
|
||||
var resp GenerateCommitMessageResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/docs/commit-message", req, &resp); err != nil {
|
||||
log.Error("AI GenerateCommitMessage failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// SummarizeChanges requests an AI summary of code changes
|
||||
func (c *Client) SummarizeChanges(ctx context.Context, req *SummarizeChangesRequest) (*SummarizeChangesResponse, error) {
|
||||
if !IsEnabled() {
|
||||
return nil, fmt.Errorf("AI service is not enabled")
|
||||
}
|
||||
|
||||
var resp SummarizeChangesResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/code/summarize", req, &resp); err != nil {
|
||||
log.Error("AI SummarizeChanges failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CheckHealth checks the health of the AI service
|
||||
func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) {
|
||||
var resp HealthCheckResponse
|
||||
if err := c.doRequest(ctx, http.MethodGet, "/health", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
206
modules/ai/types.go
Normal file
206
modules/ai/types.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ai
|
||||
|
||||
// FileDiff represents a file diff for code review
|
||||
type FileDiff struct {
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"old_path,omitempty"`
|
||||
Status string `json:"status"` // added, modified, deleted, renamed
|
||||
Patch string `json:"patch"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ReviewOptions contains options for code review
|
||||
type ReviewOptions struct {
|
||||
CheckSecurity bool `json:"check_security"`
|
||||
CheckPerformance bool `json:"check_performance"`
|
||||
CheckStyle bool `json:"check_style"`
|
||||
CheckTests bool `json:"check_tests"`
|
||||
SuggestImprovements bool `json:"suggest_improvements"`
|
||||
FocusAreas string `json:"focus_areas,omitempty"`
|
||||
LanguageHints string `json:"language_hints,omitempty"`
|
||||
}
|
||||
|
||||
// ReviewComment represents a code review comment
|
||||
type ReviewComment struct {
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line"`
|
||||
EndLine int `json:"end_line,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Severity string `json:"severity"` // info, warning, error, critical
|
||||
Category string `json:"category"` // security, performance, style, bug
|
||||
SuggestedFix string `json:"suggested_fix,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityIssue represents a security issue found during review
|
||||
type SecurityIssue struct {
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line"`
|
||||
IssueType string `json:"issue_type"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Remediation string `json:"remediation"`
|
||||
}
|
||||
|
||||
// SecurityAnalysis contains security analysis results
|
||||
type SecurityAnalysis struct {
|
||||
Issues []SecurityIssue `json:"issues"`
|
||||
RiskScore int `json:"risk_score"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// ReviewPullRequestRequest is the request for reviewing a pull request
|
||||
type ReviewPullRequestRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
PullRequestID int64 `json:"pull_request_id"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
Files []FileDiff `json:"files"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
PRDescription string `json:"pr_description"`
|
||||
Options ReviewOptions `json:"options"`
|
||||
}
|
||||
|
||||
// ReviewPullRequestResponse is the response from reviewing a pull request
|
||||
type ReviewPullRequestResponse struct {
|
||||
Summary string `json:"summary"`
|
||||
Comments []ReviewComment `json:"comments"`
|
||||
Verdict string `json:"verdict"` // approve, request_changes, comment
|
||||
Suggestions []string `json:"suggestions"`
|
||||
Security SecurityAnalysis `json:"security"`
|
||||
EstimatedReviewMinutes int `json:"estimated_review_minutes"`
|
||||
}
|
||||
|
||||
// TriageIssueRequest is the request for triaging an issue
|
||||
type TriageIssueRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
ExistingLabels []string `json:"existing_labels"`
|
||||
AvailableLabels []string `json:"available_labels"`
|
||||
}
|
||||
|
||||
// TriageIssueResponse is the response from triaging an issue
|
||||
type TriageIssueResponse struct {
|
||||
Priority string `json:"priority"` // critical, high, medium, low
|
||||
Category string `json:"category"` // bug, feature, question, docs
|
||||
SuggestedLabels []string `json:"suggested_labels"`
|
||||
SuggestedAssignees []string `json:"suggested_assignees"`
|
||||
Summary string `json:"summary"`
|
||||
IsDuplicate bool `json:"is_duplicate"`
|
||||
DuplicateOf int64 `json:"duplicate_of,omitempty"`
|
||||
}
|
||||
|
||||
// SuggestLabelsRequest is the request for suggesting labels
|
||||
type SuggestLabelsRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
AvailableLabels []string `json:"available_labels"`
|
||||
}
|
||||
|
||||
// LabelSuggestion represents a suggested label
|
||||
type LabelSuggestion struct {
|
||||
Label string `json:"label"`
|
||||
Confidence float32 `json:"confidence"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// SuggestLabelsResponse is the response from suggesting labels
|
||||
type SuggestLabelsResponse struct {
|
||||
Suggestions []LabelSuggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
// ExplainCodeRequest is the request for explaining code
|
||||
type ExplainCodeRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
Code string `json:"code"`
|
||||
StartLine int `json:"start_line"`
|
||||
EndLine int `json:"end_line"`
|
||||
Question string `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// CodeReference represents a reference to related documentation
|
||||
type CodeReference struct {
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ExplainCodeResponse is the response from explaining code
|
||||
type ExplainCodeResponse struct {
|
||||
Explanation string `json:"explanation"`
|
||||
KeyConcepts []string `json:"key_concepts"`
|
||||
References []CodeReference `json:"references"`
|
||||
}
|
||||
|
||||
// GenerateDocumentationRequest is the request for generating documentation
|
||||
type GenerateDocumentationRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
Code string `json:"code"`
|
||||
DocType string `json:"doc_type"` // function, class, module, api
|
||||
Language string `json:"language"`
|
||||
Style string `json:"style"` // jsdoc, docstring, xml, markdown
|
||||
}
|
||||
|
||||
// DocumentationSection represents a section of documentation
|
||||
type DocumentationSection struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// GenerateDocumentationResponse is the response from generating documentation
|
||||
type GenerateDocumentationResponse struct {
|
||||
Documentation string `json:"documentation"`
|
||||
Sections []DocumentationSection `json:"sections"`
|
||||
}
|
||||
|
||||
// GenerateCommitMessageRequest is the request for generating a commit message
|
||||
type GenerateCommitMessageRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Files []FileDiff `json:"files"`
|
||||
Style string `json:"style"` // conventional, descriptive, brief
|
||||
}
|
||||
|
||||
// GenerateCommitMessageResponse is the response from generating a commit message
|
||||
type GenerateCommitMessageResponse struct {
|
||||
Message string `json:"message"`
|
||||
Alternatives []string `json:"alternatives"`
|
||||
}
|
||||
|
||||
// SummarizeChangesRequest is the request for summarizing changes
|
||||
type SummarizeChangesRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Files []FileDiff `json:"files"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
// SummarizeChangesResponse is the response from summarizing changes
|
||||
type SummarizeChangesResponse struct {
|
||||
Summary string `json:"summary"`
|
||||
BulletPoints []string `json:"bullet_points"`
|
||||
ImpactAssessment string `json:"impact_assessment"`
|
||||
}
|
||||
|
||||
// HealthCheckResponse is the response from a health check
|
||||
type HealthCheckResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Version string `json:"version"`
|
||||
ProviderStatus map[string]string `json:"provider_status"`
|
||||
License *LicenseInfo `json:"license,omitempty"`
|
||||
}
|
||||
|
||||
// LicenseInfo contains AI service license information
|
||||
type LicenseInfo struct {
|
||||
Tier string `json:"tier"`
|
||||
Customer string `json:"customer"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Features []string `json:"features"`
|
||||
SeatCount int `json:"seat_count"`
|
||||
IsTrial bool `json:"is_trial"`
|
||||
}
|
||||
64
modules/setting/ai.go
Normal file
64
modules/setting/ai.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
)
|
||||
|
||||
// AI settings for the GitCaddy AI service integration
|
||||
var AI = struct {
|
||||
Enabled bool
|
||||
ServiceURL string
|
||||
ServiceToken string
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
EnableCodeReview bool
|
||||
EnableIssueTriage bool
|
||||
EnableDocGen bool
|
||||
EnableExplainCode bool
|
||||
EnableChat bool
|
||||
MaxFileSizeKB int64
|
||||
MaxDiffLines int
|
||||
}{
|
||||
Enabled: false,
|
||||
ServiceURL: "localhost:50051",
|
||||
ServiceToken: "",
|
||||
Timeout: 30 * time.Second,
|
||||
MaxRetries: 3,
|
||||
EnableCodeReview: true,
|
||||
EnableIssueTriage: true,
|
||||
EnableDocGen: true,
|
||||
EnableExplainCode: true,
|
||||
EnableChat: true,
|
||||
MaxFileSizeKB: 500,
|
||||
MaxDiffLines: 5000,
|
||||
}
|
||||
|
||||
func loadAIFrom(rootCfg ConfigProvider) {
|
||||
sec := rootCfg.Section("ai")
|
||||
AI.Enabled = sec.Key("ENABLED").MustBool(false)
|
||||
AI.ServiceURL = sec.Key("SERVICE_URL").MustString("localhost:50051")
|
||||
AI.ServiceToken = sec.Key("SERVICE_TOKEN").MustString("")
|
||||
AI.Timeout = sec.Key("TIMEOUT").MustDuration(30 * time.Second)
|
||||
AI.MaxRetries = sec.Key("MAX_RETRIES").MustInt(3)
|
||||
AI.EnableCodeReview = sec.Key("ENABLE_CODE_REVIEW").MustBool(true)
|
||||
AI.EnableIssueTriage = sec.Key("ENABLE_ISSUE_TRIAGE").MustBool(true)
|
||||
AI.EnableDocGen = sec.Key("ENABLE_DOC_GEN").MustBool(true)
|
||||
AI.EnableExplainCode = sec.Key("ENABLE_EXPLAIN_CODE").MustBool(true)
|
||||
AI.EnableChat = sec.Key("ENABLE_CHAT").MustBool(true)
|
||||
AI.MaxFileSizeKB = sec.Key("MAX_FILE_SIZE_KB").MustInt64(500)
|
||||
AI.MaxDiffLines = sec.Key("MAX_DIFF_LINES").MustInt(5000)
|
||||
|
||||
if AI.Enabled && AI.ServiceURL == "" {
|
||||
log.Error("AI is enabled but SERVICE_URL is not configured")
|
||||
AI.Enabled = false
|
||||
}
|
||||
|
||||
if AI.Enabled {
|
||||
log.Info("AI service integration enabled, connecting to %s", AI.ServiceURL)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ func LoadSettings() {
|
||||
loadProjectFrom(CfgProvider)
|
||||
loadMimeTypeMapFrom(CfgProvider)
|
||||
loadFederationFrom(CfgProvider)
|
||||
loadAIFrom(CfgProvider)
|
||||
}
|
||||
|
||||
// LoadSettingsForInstall initializes the settings for install
|
||||
|
||||
@@ -4074,6 +4074,21 @@
|
||||
"repo.vault.tokens_read_only": "Read-only",
|
||||
"repo.vault.tokens_read_only_desc": "Solo tier tokens can only read secrets. Upgrade to Pro for write access.",
|
||||
"repo.vault.unlimited": "Unlimited",
|
||||
"repo.ai.features": "AI Features",
|
||||
"repo.ai.request_code_review": "AI Code Review",
|
||||
"repo.ai.code_review_description": "Get AI-powered feedback on code changes, including security and style suggestions.",
|
||||
"repo.ai.triage_issue": "AI Triage",
|
||||
"repo.ai.triage_description": "Automatically analyze and categorize this issue using AI.",
|
||||
"repo.ai.suggest_labels": "Suggest Labels",
|
||||
"repo.ai.service_unavailable": "AI service is currently unavailable",
|
||||
"repo.ai.code_review_disabled": "AI code review is not enabled",
|
||||
"repo.ai.issue_triage_disabled": "AI issue triage is not enabled",
|
||||
"repo.ai.review_completed": "AI code review completed successfully",
|
||||
"repo.ai.review_failed": "AI code review failed: %s",
|
||||
"repo.ai.triage_completed": "AI triage completed successfully",
|
||||
"repo.ai.triage_failed": "AI triage failed: %s",
|
||||
"repo.ai.explain_code": "Explain Code",
|
||||
"repo.ai.generate_docs": "Generate Documentation",
|
||||
"actions.runs.disk_usage": "Disk Usage",
|
||||
"actions.runs.clear_cancelled": "Clear Cancelled",
|
||||
"actions.runs.clear_failed": "Clear Failed",
|
||||
|
||||
@@ -1299,6 +1299,12 @@ func Routes() *web.Router {
|
||||
m.Delete("", repo.DeleteVaultSecret)
|
||||
})
|
||||
}) // No auth middleware - vault uses its own token auth
|
||||
// AI-powered code features
|
||||
m.Group("/ai", func() {
|
||||
m.Get("/status", repo.AIStatus)
|
||||
m.Post("/explain", reqToken(), bind(repo.ExplainCodeRequest{}), repo.AIExplainCode)
|
||||
m.Post("/generate-docs", reqToken(), bind(repo.GenerateDocRequest{}), repo.AIGenerateDocumentation)
|
||||
})
|
||||
m.Group("/keys", func() {
|
||||
m.Combo("").Get(repo.ListDeployKeys).
|
||||
Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)
|
||||
@@ -1407,6 +1413,8 @@ func Routes() *web.Router {
|
||||
m.Combo("/requested_reviewers", reqToken()).
|
||||
Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests).
|
||||
Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests)
|
||||
// AI-powered code review
|
||||
m.Post("/ai/review", reqToken(), repo.AIReviewPullRequest)
|
||||
})
|
||||
m.Get("/{base}/*", repo.GetPullRequestByBaseHead)
|
||||
}, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo())
|
||||
@@ -1603,6 +1611,11 @@ func Routes() *web.Router {
|
||||
Delete(reqToken(), reqAdmin(), repo.UnpinIssue)
|
||||
m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin)
|
||||
})
|
||||
// AI-powered issue features
|
||||
m.Group("/ai", func() {
|
||||
m.Post("/triage", reqToken(), repo.AITriageIssue)
|
||||
m.Post("/suggest-labels", reqToken(), repo.AISuggestLabels)
|
||||
})
|
||||
m.Group("/lock", func() {
|
||||
m.Combo("").
|
||||
Put(bind(api.LockIssueOption{}), repo.LockIssue).
|
||||
|
||||
412
routers/api/v1/repo/ai.go
Normal file
412
routers/api/v1/repo/ai.go
Normal file
@@ -0,0 +1,412 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
||||
"code.gitcaddy.com/server/v3/modules/ai"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
"code.gitcaddy.com/server/v3/services/context"
|
||||
ai_service "code.gitcaddy.com/server/v3/services/ai"
|
||||
)
|
||||
|
||||
// AIReviewPullRequest performs an AI-powered code review on a pull request
|
||||
func AIReviewPullRequest(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/ai/review repository aiReviewPullRequest
|
||||
// ---
|
||||
// summary: Request AI code review for a pull request
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: index
|
||||
// in: path
|
||||
// description: index of the pull request
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/AIReviewResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "503":
|
||||
// "$ref": "#/responses/serviceUnavailable"
|
||||
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI service is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !setting.AI.EnableCodeReview {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI code review is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
review, err := ai_service.ReviewPullRequest(ctx, pr)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, review)
|
||||
}
|
||||
|
||||
// AITriageIssue performs AI-powered triage on an issue
|
||||
func AITriageIssue(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/issues/{index}/ai/triage repository aiTriageIssue
|
||||
// ---
|
||||
// summary: Request AI triage for an issue
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: index
|
||||
// in: path
|
||||
// description: index of the issue
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/AITriageResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "503":
|
||||
// "$ref": "#/responses/serviceUnavailable"
|
||||
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI service is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !setting.AI.EnableIssueTriage {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI issue triage is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
triage, err := ai_service.TriageIssue(ctx, issue)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, triage)
|
||||
}
|
||||
|
||||
// AISuggestLabels suggests labels for an issue using AI
|
||||
func AISuggestLabels(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/issues/{index}/ai/suggest-labels repository aiSuggestLabels
|
||||
// ---
|
||||
// summary: Get AI-suggested labels for an issue
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: index
|
||||
// in: path
|
||||
// description: index of the issue
|
||||
// type: integer
|
||||
// format: int64
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/AISuggestLabelsResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "503":
|
||||
// "$ref": "#/responses/serviceUnavailable"
|
||||
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI service is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
suggestions, err := ai_service.SuggestLabels(ctx, issue)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, suggestions)
|
||||
}
|
||||
|
||||
// ExplainCodeRequest is the request body for explaining code
|
||||
type ExplainCodeRequest struct {
|
||||
Code string `json:"code" binding:"Required"`
|
||||
FilePath string `json:"file_path"`
|
||||
StartLine int `json:"start_line"`
|
||||
EndLine int `json:"end_line"`
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
// AIExplainCode explains code using AI
|
||||
func AIExplainCode(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/ai/explain repository aiExplainCode
|
||||
// ---
|
||||
// summary: Get AI explanation for code
|
||||
// produces:
|
||||
// - application/json
|
||||
// consumes:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ExplainCodeRequest"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/AIExplainCodeResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "503":
|
||||
// "$ref": "#/responses/serviceUnavailable"
|
||||
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI service is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !setting.AI.EnableExplainCode {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI code explanation is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req ExplainCodeRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
explanation, err := ai_service.ExplainCode(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.StartLine, req.EndLine, req.Question)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, explanation)
|
||||
}
|
||||
|
||||
// GenerateDocRequest is the request body for generating documentation
|
||||
type GenerateDocRequest struct {
|
||||
Code string `json:"code" binding:"Required"`
|
||||
FilePath string `json:"file_path"`
|
||||
DocType string `json:"doc_type"` // function, class, module, api
|
||||
Language string `json:"language"`
|
||||
Style string `json:"style"` // jsdoc, docstring, xml, markdown
|
||||
}
|
||||
|
||||
// AIGenerateDocumentation generates documentation using AI
|
||||
func AIGenerateDocumentation(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/ai/generate-docs repository aiGenerateDocumentation
|
||||
// ---
|
||||
// summary: Generate documentation for code using AI
|
||||
// produces:
|
||||
// - application/json
|
||||
// consumes:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/GenerateDocRequest"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/AIGenerateDocResponse"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
// "503":
|
||||
// "$ref": "#/responses/serviceUnavailable"
|
||||
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI service is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !setting.AI.EnableDocGen {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "AI documentation generation is not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req GenerateDocRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
docs, err := ai_service.GenerateDocumentation(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.DocType, req.Language, req.Style)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, docs)
|
||||
}
|
||||
|
||||
// AIStatus returns the status of AI features
|
||||
func AIStatus(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/ai/status repository aiStatus
|
||||
// ---
|
||||
// summary: Get AI service status for this repository
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/AIStatusResponse"
|
||||
|
||||
status := map[string]any{
|
||||
"enabled": ai_service.IsEnabled(),
|
||||
"code_review_enabled": setting.AI.EnableCodeReview,
|
||||
"issue_triage_enabled": setting.AI.EnableIssueTriage,
|
||||
"doc_gen_enabled": setting.AI.EnableDocGen,
|
||||
"explain_code_enabled": setting.AI.EnableExplainCode,
|
||||
"chat_enabled": setting.AI.EnableChat,
|
||||
}
|
||||
|
||||
if ai_service.IsEnabled() {
|
||||
client := ai.GetClient()
|
||||
health, err := client.CheckHealth(ctx)
|
||||
if err != nil {
|
||||
status["service_healthy"] = false
|
||||
status["service_error"] = err.Error()
|
||||
} else {
|
||||
status["service_healthy"] = health.Healthy
|
||||
status["service_version"] = health.Version
|
||||
if health.License != nil {
|
||||
status["license_tier"] = health.License.Tier
|
||||
status["license_valid"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, status)
|
||||
}
|
||||
143
routers/web/repo/ai.go
Normal file
143
routers/web/repo/ai.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
"code.gitcaddy.com/server/v3/modules/templates"
|
||||
"code.gitcaddy.com/server/v3/services/context"
|
||||
ai_service "code.gitcaddy.com/server/v3/services/ai"
|
||||
)
|
||||
|
||||
// AIReviewPullRequest handles the AI review request for a pull request
|
||||
func AIReviewPullRequest(ctx *context.Context) {
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.service_unavailable"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
|
||||
return
|
||||
}
|
||||
|
||||
if !setting.AI.EnableCodeReview {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.code_review_disabled"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByIndex", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !issue.IsPull {
|
||||
ctx.NotFound("Not a pull request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
pr, err := issues_model.GetPullRequestByIssueID(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetPullRequestByIssueID", err)
|
||||
return
|
||||
}
|
||||
|
||||
review, err := ai_service.ReviewPullRequest(ctx, pr)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.review_failed", err.Error()))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
|
||||
return
|
||||
}
|
||||
|
||||
// Store the review result in session for display
|
||||
ctx.Session.Set("ai_review_result", review)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.ai.review_completed"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
|
||||
}
|
||||
|
||||
// AITriageIssue handles the AI triage request for an issue
|
||||
func AITriageIssue(ctx *context.Context) {
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.service_unavailable"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
|
||||
return
|
||||
}
|
||||
|
||||
if !setting.AI.EnableIssueTriage {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.issue_triage_disabled"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetIssueByIndex", err)
|
||||
return
|
||||
}
|
||||
|
||||
triage, err := ai_service.TriageIssue(ctx, issue)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.triage_failed", err.Error()))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
|
||||
return
|
||||
}
|
||||
|
||||
// Store the triage result in session for display
|
||||
ctx.Session.Set("ai_triage_result", triage)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.ai.triage_completed"))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
|
||||
}
|
||||
|
||||
// AISuggestLabels handles the AI label suggestion request
|
||||
func AISuggestLabels(ctx *context.Context) {
|
||||
if !ai_service.IsEnabled() {
|
||||
ctx.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": ctx.Tr("repo.ai.service_unavailable"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "Issue not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
suggestions, err := ai_service.SuggestLabels(ctx, issue)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, suggestions)
|
||||
}
|
||||
|
||||
// IsAIEnabled is a template helper to check if AI is enabled
|
||||
func IsAIEnabled() bool {
|
||||
return ai_service.IsEnabled()
|
||||
}
|
||||
|
||||
// IsAICodeReviewEnabled checks if AI code review is enabled
|
||||
func IsAICodeReviewEnabled() bool {
|
||||
return ai_service.IsEnabled() && setting.AI.EnableCodeReview
|
||||
}
|
||||
|
||||
// IsAIIssueTriageEnabled checks if AI issue triage is enabled
|
||||
func IsAIIssueTriageEnabled() bool {
|
||||
return ai_service.IsEnabled() && setting.AI.EnableIssueTriage
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register template functions
|
||||
templates.RegisterTemplateFunc("IsAIEnabled", IsAIEnabled)
|
||||
templates.RegisterTemplateFunc("IsAICodeReviewEnabled", IsAICodeReviewEnabled)
|
||||
templates.RegisterTemplateFunc("IsAIIssueTriageEnabled", IsAIIssueTriageEnabled)
|
||||
}
|
||||
@@ -1364,6 +1364,11 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue)
|
||||
m.Post("/delete", reqRepoAdmin, repo.DeleteIssue)
|
||||
m.Post("/content-history/soft-delete", repo.SoftDeleteContentHistory)
|
||||
// AI-powered issue features
|
||||
m.Group("/ai", func() {
|
||||
m.Post("/triage", repo.AITriageIssue)
|
||||
m.Get("/suggest-labels", repo.AISuggestLabels)
|
||||
})
|
||||
})
|
||||
|
||||
m.Post("/attachments", repo.UploadIssueAttachment)
|
||||
@@ -1679,6 +1684,8 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Post("/submit", web.Bind(forms.SubmitReviewForm{}), repo.SubmitReview)
|
||||
}, context.RepoMustNotBeArchived())
|
||||
})
|
||||
// AI-powered code review
|
||||
m.Post("/ai/review", repo.AIReviewPullRequest)
|
||||
})
|
||||
}, optSignIn, context.RepoAssignment, repo.MustAllowPulls, reqUnitPullsReader)
|
||||
// end "/{username}/{reponame}/pulls/{index}": repo pull request
|
||||
|
||||
327
services/ai/ai.go
Normal file
327
services/ai/ai.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// 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 ""
|
||||
}
|
||||
40
templates/repo/issue/sidebar/ai_features.tmpl
Normal file
40
templates/repo/issue/sidebar/ai_features.tmpl
Normal file
@@ -0,0 +1,40 @@
|
||||
{{if IsAIEnabled}}
|
||||
<div class="divider"></div>
|
||||
<div class="ui ai-features segment">
|
||||
<strong>{{ctx.Locale.Tr "repo.ai.features"}}</strong>
|
||||
|
||||
{{if .Issue.IsPull}}
|
||||
{{if IsAICodeReviewEnabled}}
|
||||
<div class="tw-mt-2">
|
||||
<form class="tw-inline" action="{{.RepoLink}}/pulls/{{.Issue.Index}}/ai/review" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<button class="ui tiny primary button fluid" type="submit" {{if .Issue.IsClosed}}disabled{{end}}>
|
||||
{{svg "octicon-copilot" 16}}
|
||||
{{ctx.Locale.Tr "repo.ai.request_code_review"}}
|
||||
</button>
|
||||
</form>
|
||||
<p class="tw-text-xs tw-text-muted tw-mt-1">{{ctx.Locale.Tr "repo.ai.code_review_description"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{if IsAIIssueTriageEnabled}}
|
||||
<div class="tw-mt-2">
|
||||
<form class="tw-inline" action="{{.RepoLink}}/issues/{{.Issue.Index}}/ai/triage" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<button class="ui tiny primary button fluid" type="submit" {{if .Issue.IsClosed}}disabled{{end}}>
|
||||
{{svg "octicon-copilot" 16}}
|
||||
{{ctx.Locale.Tr "repo.ai.triage_issue"}}
|
||||
</button>
|
||||
</form>
|
||||
<p class="tw-text-xs tw-text-muted tw-mt-1">{{ctx.Locale.Tr "repo.ai.triage_description"}}</p>
|
||||
</div>
|
||||
<div class="tw-mt-2">
|
||||
<button class="ui tiny button fluid ai-suggest-labels" data-url="{{.RepoLink}}/issues/{{.Issue.Index}}/ai/suggest-labels" {{if .Issue.IsClosed}}disabled{{end}}>
|
||||
{{svg "octicon-tag" 16}}
|
||||
{{ctx.Locale.Tr "repo.ai.suggest_labels"}}
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -23,4 +23,5 @@
|
||||
{{template "repo/issue/sidebar/reference_link" $}}
|
||||
{{template "repo/issue/sidebar/issue_management" $}}
|
||||
{{template "repo/issue/sidebar/allow_maintainer_edit" $}}
|
||||
{{template "repo/issue/sidebar/ai_features" $}}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user