diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 40c066c2b1..0601251be0 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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] diff --git a/modules/ai/client.go b/modules/ai/client.go new file mode 100644 index 0000000000..079829c6c2 --- /dev/null +++ b/modules/ai/client.go @@ -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 +} diff --git a/modules/ai/types.go b/modules/ai/types.go new file mode 100644 index 0000000000..561b9eca04 --- /dev/null +++ b/modules/ai/types.go @@ -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"` +} diff --git a/modules/setting/ai.go b/modules/setting/ai.go new file mode 100644 index 0000000000..54328f460a --- /dev/null +++ b/modules/setting/ai.go @@ -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) + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 00b374ad35..0e8b661830 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -219,6 +219,7 @@ func LoadSettings() { loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadFederationFrom(CfgProvider) + loadAIFrom(CfgProvider) } // LoadSettingsForInstall initializes the settings for install diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index a27f18b31c..e521f26af2 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f029951987..f885da6324 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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). diff --git a/routers/api/v1/repo/ai.go b/routers/api/v1/repo/ai.go new file mode 100644 index 0000000000..93aef65346 --- /dev/null +++ b/routers/api/v1/repo/ai.go @@ -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) +} diff --git a/routers/web/repo/ai.go b/routers/web/repo/ai.go new file mode 100644 index 0000000000..941b01a98c --- /dev/null +++ b/routers/web/repo/ai.go @@ -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) +} diff --git a/routers/web/web.go b/routers/web/web.go index bf525755d4..aee94f9fbe 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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 diff --git a/services/ai/ai.go b/services/ai/ai.go new file mode 100644 index 0000000000..adf900a23f --- /dev/null +++ b/services/ai/ai.go @@ -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 "" +} diff --git a/templates/repo/issue/sidebar/ai_features.tmpl b/templates/repo/issue/sidebar/ai_features.tmpl new file mode 100644 index 0000000000..9a9e1d5f3e --- /dev/null +++ b/templates/repo/issue/sidebar/ai_features.tmpl @@ -0,0 +1,40 @@ +{{if IsAIEnabled}} +
+{{ctx.Locale.Tr "repo.ai.code_review_description"}}
+{{ctx.Locale.Tr "repo.ai.triage_description"}}
+