2
0

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:
2026-01-19 11:06:39 -05:00
parent 70aee25079
commit 61e835358c
13 changed files with 1470 additions and 0 deletions

View File

@@ -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
View 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
View 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
View 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)
}
}

View File

@@ -219,6 +219,7 @@ func LoadSettings() {
loadProjectFrom(CfgProvider)
loadMimeTypeMapFrom(CfgProvider)
loadFederationFrom(CfgProvider)
loadAIFrom(CfgProvider)
}
// LoadSettingsForInstall initializes the settings for install

View File

@@ -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",

View File

@@ -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
View 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
View 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)
}

View File

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

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

View File

@@ -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>