diff --git a/modules/ai/types.go b/modules/ai/types.go index 110ab45959..a2b94183f8 100644 --- a/modules/ai/types.go +++ b/modules/ai/types.go @@ -187,21 +187,31 @@ type SummarizeChangesResponse struct { ImpactAssessment string `json:"impact_assessment"` } +// IssueComment represents a comment on an issue for AI context +type IssueComment struct { + Author string `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"created_at,omitempty"` +} + // GenerateIssueResponseRequest is the request for generating an AI response to an issue type GenerateIssueResponseRequest struct { - RepoID int64 `json:"repo_id"` - IssueID int64 `json:"issue_id"` - Title string `json:"title"` - Body string `json:"body"` - CustomInstructions string `json:"custom_instructions,omitempty"` + RepoID int64 `json:"repo_id"` + IssueID int64 `json:"issue_id"` + Title string `json:"title"` + Body string `json:"body"` + Comments []IssueComment `json:"comments,omitempty"` + ResponseType string `json:"response_type,omitempty"` // clarification, solution, acknowledgment + CustomInstructions string `json:"custom_instructions,omitempty"` } // GenerateIssueResponseResponse is the response from generating an issue response type GenerateIssueResponseResponse struct { - Response string `json:"response"` - Confidence float64 `json:"confidence"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + Response string `json:"response"` + FollowUpQuestions []string `json:"follow_up_questions,omitempty"` + Confidence float64 `json:"confidence"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` } // HealthCheckResponse is the response from a health check diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index ce70f7ad14..d3c1e7ebce 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2115,6 +2115,38 @@ "repo.wishlist.item_closed": "Wishlist item has been closed.", "repo.wishlist.item_reopened": "Wishlist item has been reopened.", "repo.wishlist.cannot_vote_closed": "Cannot vote on a closed item.", + "repo.settings.ai": "AI", + "repo.settings.ai.globally_disabled": "AI features are disabled by the system administrator.", + "repo.settings.ai.enable": "Enable AI", + "repo.settings.ai.enable_desc": "Enable AI-powered operations for this repository (code review, issue triage, auto-respond, etc.)", + "repo.settings.ai.not_enabled": "AI is not enabled for this repository. Enable it first.", + "repo.settings.ai.disabled_by_admin": "(disabled by admin)", + "repo.settings.ai.tier1": "Tier 1: Light AI Operations", + "repo.settings.ai.auto_respond_issues": "Automatically respond to new issues with helpful suggestions", + "repo.settings.ai.auto_review_prs": "Automatically review pull requests for code quality and security", + "repo.settings.ai.auto_triage_issues": "Automatically triage and label new issues", + "repo.settings.ai.auto_inspect_workflows": "Automatically inspect workflow changes for issues", + "repo.settings.ai.tier2": "Tier 2: Agent Mode", + "repo.settings.ai.agent_mode": "Enable agent mode (AI can modify code, create branches, and submit PRs)", + "repo.settings.ai.agent_trigger_labels": "Trigger Labels", + "repo.settings.ai.agent_trigger_labels_desc": "Comma-separated list of labels that trigger agent mode when added to an issue (e.g. ai-fix, ai-implement)", + "repo.settings.ai.agent_max_run_minutes": "Max Run Time (minutes)", + "repo.settings.ai.escalation": "Escalation", + "repo.settings.ai.escalate_to_staff": "Escalate to staff when AI confidence is low or agent fails", + "repo.settings.ai.escalation_label": "Escalation Label", + "repo.settings.ai.escalation_assign_team": "Assign to Team", + "repo.settings.ai.provider": "Provider & Model", + "repo.settings.ai.preferred_provider": "Preferred Provider", + "repo.settings.ai.preferred_model": "Preferred Model", + "repo.settings.ai.inherit_default": "Inherit from org/system default", + "repo.settings.ai.resolved_provider": "Currently using", + "repo.settings.ai.instructions": "Custom Instructions", + "repo.settings.ai.system_instructions": "System Instructions", + "repo.settings.ai.system_instructions_desc": "General instructions for all AI operations on this repository", + "repo.settings.ai.review_instructions": "Code Review Instructions", + "repo.settings.ai.review_instructions_desc": "Specific instructions for AI code reviews (focus areas, coding standards, etc.)", + "repo.settings.ai.issue_instructions": "Issue Response Instructions", + "repo.settings.ai.issue_instructions_desc": "Specific instructions for AI issue responses (tone, common answers, project context, etc.)", "repo.settings.wishlist": "Wishlist", "repo.settings.wishlist.enable": "Enable Wishlist", "repo.settings.wishlist.enable_help": "When enabled, a Wishlist tab appears on the repository where users can submit and vote on feature requests.", @@ -3018,6 +3050,20 @@ "org.settings.license_file_found": "Found existing license file in .profile: %s", "org.settings.license_overwrite_warning": "This will overwrite the existing %s file in your .profile repository.", "org.settings.license_create_confirm": "This will create a LICENSE.md file in your .profile repository.", + "org.settings.ai": "AI", + "org.settings.ai.provider": "AI Provider & Configuration", + "org.settings.ai.provider_label": "Provider", + "org.settings.ai.model_label": "Model", + "org.settings.ai.api_key": "API Key", + "org.settings.ai.api_key_configured": "An API key is currently configured. Enter a new value to change it.", + "org.settings.ai.rate_limits": "Rate Limits", + "org.settings.ai.max_ops_per_hour": "Max Operations Per Hour", + "org.settings.ai.max_ops_per_hour_desc": "Set to 0 to use the system default. This limit applies per-repository within the organization.", + "org.settings.ai.allowed_ops": "Allowed Operations", + "org.settings.ai.allowed_ops_placeholder": "code-review, issue-triage, issue-response", + "org.settings.ai.allowed_ops_desc": "Comma-separated list of allowed operation types. Leave empty to allow all operations.", + "org.settings.ai.advanced": "Advanced", + "org.settings.ai.agent_mode_allowed": "Allow Agent Mode for repositories in this organization", "org.settings.homepage_pinning": "Homepage Visibility", "org.settings.pin_to_homepage": "Pin this organization to the homepage", "org.settings.pin_to_homepage_help": "When enabled, this organization will be featured on the public homepage. Only administrators can change this setting.", diff --git a/routers/web/org/setting_ai.go b/routers/web/org/setting_ai.go new file mode 100644 index 0000000000..78c10509bc --- /dev/null +++ b/routers/web/org/setting_ai.go @@ -0,0 +1,96 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + ai_model "code.gitcaddy.com/server/v3/models/ai" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + shared_user "code.gitcaddy.com/server/v3/routers/web/shared/user" + "code.gitcaddy.com/server/v3/services/context" +) + +const tplSettingsAI templates.TplName = "org/settings/ai" + +// SettingsAI shows the organization AI settings page +func SettingsAI(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("org.settings.ai") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsAI"] = true + ctx.Data["AIGlobalEnabled"] = setting.AI.Enabled + ctx.Data["AllowAgentMode"] = setting.AI.AllowAgentMode + + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + + if !setting.AI.Enabled { + ctx.HTML(http.StatusOK, tplSettingsAI) + return + } + + orgSettings, err := ai_model.GetOrgAISettings(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.ServerError("GetOrgAISettings", err) + return + } + + if orgSettings == nil { + orgSettings = &ai_model.OrgAISettings{} + } + + ctx.Data["OrgAISettings"] = orgSettings + ctx.Data["HasAPIKey"] = orgSettings.APIKeyEncrypted != "" + + ctx.HTML(http.StatusOK, tplSettingsAI) +} + +// SettingsAIPost handles the org AI settings form submission +func SettingsAIPost(ctx *context.Context) { + if !setting.AI.Enabled { + ctx.Flash.Error(ctx.Tr("repo.settings.ai.globally_disabled")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/ai") + return + } + + org := ctx.Org.Organization + + // Load existing or create new + orgSettings, err := ai_model.GetOrgAISettings(ctx, org.ID) + if err != nil { + ctx.ServerError("GetOrgAISettings", err) + return + } + if orgSettings == nil { + orgSettings = &ai_model.OrgAISettings{OrgID: org.ID} + } + + // Update fields from form + orgSettings.Provider = ctx.FormString("provider") + orgSettings.Model = ctx.FormString("model") + + // Only update API key if a new one was provided (don't clear existing) + apiKey := ctx.FormString("api_key") + if apiKey != "" { + if err := orgSettings.SetAPIKey(apiKey); err != nil { + ctx.ServerError("SetAPIKey", err) + return + } + } + + orgSettings.MaxOpsPerHour = ctx.FormInt("max_ops_per_hour") + orgSettings.AllowedOps = ctx.FormString("allowed_ops") + orgSettings.AgentModeAllowed = ctx.FormBool("agent_mode_allowed") && setting.AI.AllowAgentMode + + if err := ai_model.CreateOrUpdateOrgAISettings(ctx, orgSettings); err != nil { + ctx.ServerError("CreateOrUpdateOrgAISettings", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/ai") +} diff --git a/routers/web/repo/setting/ai.go b/routers/web/repo/setting/ai.go new file mode 100644 index 0000000000..aaf32aebbc --- /dev/null +++ b/routers/web/repo/setting/ai.go @@ -0,0 +1,220 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strconv" + "strings" + + ai_model "code.gitcaddy.com/server/v3/models/ai" + repo_model "code.gitcaddy.com/server/v3/models/repo" + unit_model "code.gitcaddy.com/server/v3/models/unit" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/services/context" + repo_service "code.gitcaddy.com/server/v3/services/repository" +) + +const tplRepoAISettings templates.TplName = "repo/settings/ai" + +// AISettings renders the AI settings page for a repository +func AISettings(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.ai") + ctx.Data["PageIsSettingsAI"] = true + ctx.Data["AIGlobalEnabled"] = setting.AI.Enabled + ctx.Data["AllowAutoRespond"] = setting.AI.AllowAutoRespond + ctx.Data["AllowAutoReview"] = setting.AI.AllowAutoReview + ctx.Data["AllowAgentMode"] = setting.AI.AllowAgentMode + + if !setting.AI.Enabled { + ctx.HTML(http.StatusOK, tplRepoAISettings) + return + } + + aiUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeAI) + if err != nil && !repo_model.IsErrUnitTypeNotExist(err) { + ctx.ServerError("GetUnit", err) + return + } + + if aiUnit != nil && aiUnit.ID != 0 { + ctx.Data["AIEnabled"] = true + cfg := aiUnit.AIConfig() + ctx.Data["AIConfig"] = cfg + + // Resolve cascade values + var orgID int64 + if ctx.Repo.Repository.Owner.IsOrganization() { + orgID = ctx.Repo.Repository.OwnerID + } + ctx.Data["ResolvedProvider"] = ai_model.ResolveProvider(ctx, orgID, cfg.PreferredProvider) + ctx.Data["ResolvedModel"] = ai_model.ResolveModel(ctx, orgID, cfg.PreferredModel) + } else { + ctx.Data["AIEnabled"] = false + ctx.Data["AIConfig"] = &repo_model.AIConfig{} + } + + ctx.HTML(http.StatusOK, tplRepoAISettings) +} + +// AISettingsPost handles AI settings form submissions +func AISettingsPost(ctx *context.Context) { + action := ctx.FormString("action") + redirectURL := ctx.Repo.RepoLink + "/settings/ai" + + switch action { + case "ai_unit": + aiSettingsPostUnit(ctx) + case "ai_tier1": + aiSettingsPostTier1(ctx) + case "ai_tier2": + aiSettingsPostTier2(ctx) + case "ai_escalation": + aiSettingsPostEscalation(ctx) + case "ai_provider": + aiSettingsPostProvider(ctx) + case "ai_instructions": + aiSettingsPostInstructions(ctx) + default: + ctx.Flash.Error("Unknown action") + } + + ctx.Redirect(redirectURL) +} + +func aiSettingsPostUnit(ctx *context.Context) { + enableAI := ctx.FormBool("enable_ai") + repo := ctx.Repo.Repository + + var err error + if enableAI { + err = repo_service.UpdateRepositoryUnits(ctx, repo, []repo_model.RepoUnit{ + { + RepoID: repo.ID, + Type: unit_model.TypeAI, + Config: &repo_model.AIConfig{}, + }, + }, nil) + } else { + err = repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeAI}) + } + + if err != nil { + ctx.ServerError("UpdateRepositoryUnits", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) +} + +func getOrCreateAIUnit(ctx *context.Context) *repo_model.RepoUnit { + aiUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeAI) + if err != nil || aiUnit.ID == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.ai.not_enabled")) + return nil + } + return aiUnit +} + +func saveAIUnit(ctx *context.Context, aiUnit *repo_model.RepoUnit) { + if err := repo_model.UpdateRepoUnit(ctx, aiUnit); err != nil { + ctx.ServerError("UpdateRepoUnit", err) + return + } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) +} + +func aiSettingsPostTier1(ctx *context.Context) { + aiUnit := getOrCreateAIUnit(ctx) + if aiUnit == nil { + return + } + cfg := aiUnit.AIConfig() + + cfg.AutoRespondToIssues = ctx.FormBool("auto_respond_issues") && setting.AI.AllowAutoRespond + cfg.AutoReviewPRs = ctx.FormBool("auto_review_prs") && setting.AI.AllowAutoReview + cfg.AutoTriageIssues = ctx.FormBool("auto_triage_issues") + cfg.AutoInspectWorkflows = ctx.FormBool("auto_inspect_workflows") + + aiUnit.Config = cfg + saveAIUnit(ctx, aiUnit) +} + +func aiSettingsPostTier2(ctx *context.Context) { + aiUnit := getOrCreateAIUnit(ctx) + if aiUnit == nil { + return + } + cfg := aiUnit.AIConfig() + + cfg.AgentModeEnabled = ctx.FormBool("agent_mode_enabled") && setting.AI.AllowAgentMode + + labelsStr := strings.TrimSpace(ctx.FormString("agent_trigger_labels")) + if labelsStr != "" { + labels := strings.Split(labelsStr, ",") + trimmed := make([]string, 0, len(labels)) + for _, l := range labels { + l = strings.TrimSpace(l) + if l != "" { + trimmed = append(trimmed, l) + } + } + cfg.AgentTriggerLabels = trimmed + } else { + cfg.AgentTriggerLabels = nil + } + + maxMin, _ := strconv.Atoi(ctx.FormString("agent_max_run_minutes")) + if maxMin >= 5 && maxMin <= 120 { + cfg.AgentMaxRunMinutes = maxMin + } + + aiUnit.Config = cfg + saveAIUnit(ctx, aiUnit) +} + +func aiSettingsPostEscalation(ctx *context.Context) { + aiUnit := getOrCreateAIUnit(ctx) + if aiUnit == nil { + return + } + cfg := aiUnit.AIConfig() + + cfg.EscalateToStaff = ctx.FormBool("escalate_to_staff") + cfg.EscalationLabel = strings.TrimSpace(ctx.FormString("escalation_label")) + cfg.EscalationAssignTeam = strings.TrimSpace(ctx.FormString("escalation_assign_team")) + + aiUnit.Config = cfg + saveAIUnit(ctx, aiUnit) +} + +func aiSettingsPostProvider(ctx *context.Context) { + aiUnit := getOrCreateAIUnit(ctx) + if aiUnit == nil { + return + } + cfg := aiUnit.AIConfig() + + cfg.PreferredProvider = ctx.FormString("preferred_provider") + cfg.PreferredModel = strings.TrimSpace(ctx.FormString("preferred_model")) + + aiUnit.Config = cfg + saveAIUnit(ctx, aiUnit) +} + +func aiSettingsPostInstructions(ctx *context.Context) { + aiUnit := getOrCreateAIUnit(ctx) + if aiUnit == nil { + return + } + cfg := aiUnit.AIConfig() + + cfg.SystemInstructions = ctx.FormString("system_instructions") + cfg.ReviewInstructions = ctx.FormString("review_instructions") + cfg.IssueInstructions = ctx.FormString("issue_instructions") + + aiUnit.Config = cfg + saveAIUnit(ctx, aiUnit) +} diff --git a/routers/web/web.go b/routers/web/web.go index a3d26a0529..892233c8ef 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1086,6 +1086,11 @@ func registerWebRoutes(m *web.Router) { m.Post("/remove", org.SettingsPinnedRemove) }) + m.Group("/ai", func() { + m.Get("", org.SettingsAI) + m.Post("", org.SettingsAIPost) + }) + m.Group("/actions", func() { m.Get("", org_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() @@ -1353,6 +1358,10 @@ func registerWebRoutes(m *web.Router) { m.Post("/products/delete", repo_setting.SubscriptionsProductDelete) m.Get("/clients", repo_setting.SubscriptionsClients) }) + m.Group("/ai", func() { + m.Get("", repo_setting.AISettings) + m.Post("", repo_setting.AISettingsPost) + }) m.Group("/wishlist", func() { m.Get("", repo_setting.WishlistSettings) m.Post("", repo_setting.WishlistSettingsPost) diff --git a/services/ai/agent.go b/services/ai/agent.go index 206b7ae86d..e7ec0755ef 100644 --- a/services/ai/agent.go +++ b/services/ai/agent.go @@ -8,16 +8,25 @@ import ( "fmt" ai_model "code.gitcaddy.com/server/v3/models/ai" + actions_model "code.gitcaddy.com/server/v3/models/actions" issues_model "code.gitcaddy.com/server/v3/models/issues" repo_model "code.gitcaddy.com/server/v3/models/repo" user_model "code.gitcaddy.com/server/v3/models/user" + "code.gitcaddy.com/server/v3/modules/gitrepo" "code.gitcaddy.com/server/v3/modules/log" + "code.gitcaddy.com/server/v3/modules/reqctx" + actions_service "code.gitcaddy.com/server/v3/services/actions" issue_service "code.gitcaddy.com/server/v3/services/issue" + + "github.com/nektos/act/pkg/model" ) +// AgentWorkflowFile is the workflow file name expected in the repo for AI agent operations +const AgentWorkflowFile = "ai-agent.yml" + // triggerAgentWorkflow triggers a Tier 2 agent Actions workflow for advanced AI work. -// It posts a comment on the issue explaining that the agent is being dispatched, -// and returns the action run ID for tracking. +// It dispatches the ai-agent.yml workflow in the repo with issue context as inputs, +// and posts a comment on the issue explaining that the agent is being dispatched. func triggerAgentWorkflow(ctx context.Context, repo *repo_model.Repository, aiCfg *repo_model.AIConfig, opLog *ai_model.OperationLog) (int64, error) { issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID) if err != nil { @@ -25,32 +34,82 @@ func triggerAgentWorkflow(ctx context.Context, repo *repo_model.Repository, aiCf } issue.Repo = repo + // Open git repo for dispatch + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return 0, fmt.Errorf("failed to open git repo: %w", err) + } + defer gitRepo.Close() + + // Resolve default branch + ref := repo.DefaultBranch + if ref == "" { + ref = "main" + } + botUser := user_model.NewAIUser() - // Post a comment that the agent is being dispatched - comment := fmt.Sprintf( + // Truncate issue body for workflow input (Actions inputs have length limits) + issueBody := issue.Content + if len(issueBody) > 2000 { + issueBody = issueBody[:2000] + "\n\n[truncated]" + } + + // Dispatch the workflow (DispatchActionWorkflow requires reqctx.RequestContext) + dispatchCtx, finished := reqctx.NewRequestContext(ctx, "ai-agent-dispatch") + defer finished() + err = actions_service.DispatchActionWorkflow(reqctx.FromContext(dispatchCtx), botUser, repo, gitRepo, AgentWorkflowFile, ref, + func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + // Populate inputs defined in the workflow + for name, config := range workflowDispatch.Inputs { + switch name { + case "issue_number": + inputs[name] = fmt.Sprintf("%d", issue.Index) + case "issue_title": + inputs[name] = issue.Title + case "issue_body": + inputs[name] = issueBody + case "max_run_minutes": + inputs[name] = fmt.Sprintf("%d", getAgentMaxRunMinutes(aiCfg)) + case "system_instructions": + inputs[name] = aiCfg.SystemInstructions + default: + inputs[name] = config.Default + } + } + return nil + }, + ) + if err != nil { + return 0, fmt.Errorf("failed to dispatch agent workflow: %w", err) + } + + // Find the run that was just created to get its ID + var runID int64 + latestRun, err := actions_model.GetLatestRun(ctx, repo.ID) + if err == nil && latestRun != nil { + runID = latestRun.ID + } + + // Post a comment on the issue + commentBody := fmt.Sprintf( "An AI agent has been dispatched to investigate and work on this issue. "+ "I'll create a pull request with the proposed changes once complete.\n\n"+ - "**Operation:** agent-fix\n"+ - "**Max runtime:** %d minutes", + "**Operation:** `agent-fix`\n"+ + "**Max runtime:** %d minutes\n"+ + "**Workflow:** `%s`", getAgentMaxRunMinutes(aiCfg), + AgentWorkflowFile, ) + if runID > 0 { + commentBody += fmt.Sprintf("\n**Run:** [#%d](%s/actions/runs/%d)", runID, repo.HTMLURL(), runID) + } - if _, err := issue_service.CreateIssueComment(ctx, botUser, repo, issue, comment, nil); err != nil { + if _, err := issue_service.CreateIssueComment(ctx, botUser, repo, issue, commentBody, nil); err != nil { log.Error("Agent: failed to post dispatch comment on issue #%d: %v", issue.Index, err) } - // TODO: Implement actual workflow dispatch via services/actions.DispatchActionWorkflow() - // This requires: - // 1. A workflow template stored in the repo or generated dynamically - // 2. Injecting the resolved API key and model as workflow inputs/secrets - // 3. Labeling the job to run on ai-runner labeled runners - // - // For now, log the intent and return 0 as a placeholder run ID. - // The full implementation will be completed when Actions runner integration is ready. - log.Info("Agent: would dispatch workflow for issue #%d in repo %s (placeholder)", issue.Index, repo.FullName()) - - return 0, nil + return runID, nil } // getAgentMaxRunMinutes returns the configured max run time, with a default fallback @@ -60,3 +119,125 @@ func getAgentMaxRunMinutes(aiCfg *repo_model.AIConfig) int { } return 30 // default 30 minutes } + +// AgentWorkflowTemplate is the recommended workflow file content for AI agent operations. +// Repo owners should place this in .gitea/workflows/ai-agent.yml +const AgentWorkflowTemplate = `# GitCaddy AI Agent Workflow +# Place this file in .gitea/workflows/ai-agent.yml to enable Tier 2 AI operations. +# Requires a runner with the 'ai-runner' label and Claude Code CLI installed. + +name: AI Agent Fix +on: + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to fix' + required: true + issue_title: + description: 'Issue title' + required: true + issue_body: + description: 'Issue body/description' + required: false + default: '' + max_run_minutes: + description: 'Maximum run time in minutes' + required: false + default: '30' + system_instructions: + description: 'Custom system instructions for the AI' + required: false + default: '' + +jobs: + ai-fix: + runs-on: [ai-runner] + timeout-minutes: ${{ inputs.max_run_minutes }} + permissions: + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create working branch + run: | + git checkout -b ai/fix-issue-${{ inputs.issue_number }} + + - name: Run Claude Code + env: + ANTHROPIC_API_KEY: ${{ secrets.AI_API_KEY }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + ISSUE_TITLE: ${{ inputs.issue_title }} + ISSUE_BODY: ${{ inputs.issue_body }} + SYSTEM_INSTRUCTIONS: ${{ inputs.system_instructions }} + run: | + PROMPT="Investigate and fix issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}" + if [ -n "${ISSUE_BODY}" ]; then + PROMPT="${PROMPT}\n\nDescription:\n${ISSUE_BODY}" + fi + + SYSTEM="You are an AI agent working on a code repository. Your goal is to investigate the issue, understand the codebase, make the necessary changes, and ensure the fix is correct." + if [ -n "${SYSTEM_INSTRUCTIONS}" ]; then + SYSTEM="${SYSTEM}\n\nAdditional instructions from the repository maintainer:\n${SYSTEM_INSTRUCTIONS}" + fi + + claude --print \ + --system-prompt "${SYSTEM}" \ + --prompt "${PROMPT}" \ + --allowedTools "Edit,Read,Write,Bash,Grep,Glob" \ + --max-turns 50 + + - name: Check for changes + id: changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + if: steps.changes.outputs.has_changes == 'true' + run: | + git config user.name "GitCaddy AI" + git config user.email "ai@gitcaddy.com" + git add -A + git commit -m "fix: address issue #${{ inputs.issue_number }} + + AI-generated fix for: ${{ inputs.issue_title }} + + Co-Authored-By: GitCaddy AI " + git push origin ai/fix-issue-${{ inputs.issue_number }} + + - name: Create pull request + if: steps.changes.outputs.has_changes == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Use gitea API to create PR (works on Gitea instances) + curl -s -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls" \ + -d "{ + \"title\": \"fix: address issue #${{ inputs.issue_number }}\", + \"body\": \"AI-generated fix for #${{ inputs.issue_number }}: ${{ inputs.issue_title }}\n\nThis PR was created automatically by the GitCaddy AI agent.\", + \"head\": \"ai/fix-issue-${{ inputs.issue_number }}\", + \"base\": \"$(git remote show origin | grep 'HEAD branch' | cut -d: -f2 | tr -d ' ')\" + }" + + - name: Comment on issue if no changes + if: steps.changes.outputs.has_changes == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + curl -s -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues/${{ inputs.issue_number }}/comments" \ + -d '{"body": "I investigated this issue but was unable to determine code changes needed. A human reviewer should take a look."}' +` diff --git a/templates/org/settings/ai.tmpl b/templates/org/settings/ai.tmpl new file mode 100644 index 0000000000..1bb4f190de --- /dev/null +++ b/templates/org/settings/ai.tmpl @@ -0,0 +1,77 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings ai")}} + +
+ {{if not .AIGlobalEnabled}} +
+ {{ctx.Locale.Tr "repo.settings.ai.globally_disabled"}} +
+ {{else}} +

+ {{ctx.Locale.Tr "org.settings.ai.provider"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+ + +
+ +
+ + +
+ +
+ + + {{if .HasAPIKey}} +

{{ctx.Locale.Tr "org.settings.ai.api_key_configured"}}

+ {{end}} +
+ +
+ +
{{ctx.Locale.Tr "org.settings.ai.rate_limits"}}
+ +
+ + +

{{ctx.Locale.Tr "org.settings.ai.max_ops_per_hour_desc"}}

+
+ +
+ + +

{{ctx.Locale.Tr "org.settings.ai.allowed_ops_desc"}}

+
+ +
+ +
{{ctx.Locale.Tr "org.settings.ai.advanced"}}
+ +
+
+ + +
+ {{if not .AllowAgentMode}}{{ctx.Locale.Tr "repo.settings.ai.disabled_by_admin"}}{{end}} +
+ +
+ +
+ +
+
+
+ {{end}} +
+ +{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 8aa2a35e66..42a2ea7898 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -31,6 +31,9 @@ {{ctx.Locale.Tr "packages.title"}} {{end}} + + {{ctx.Locale.Tr "org.settings.ai"}} + {{if .EnableActions}}
{{ctx.Locale.Tr "actions.actions"}} diff --git a/templates/repo/settings/ai.tmpl b/templates/repo/settings/ai.tmpl new file mode 100644 index 0000000000..2e535a5992 --- /dev/null +++ b/templates/repo/settings/ai.tmpl @@ -0,0 +1,208 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings ai")}} +
+ {{if not .AIGlobalEnabled}} +
+ {{ctx.Locale.Tr "repo.settings.ai.globally_disabled"}} +
+ {{else}} +

+ {{ctx.Locale.Tr "repo.settings.ai.enable"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+
+ + +
+
+
+
+ +
+
+
+ + {{if .AIEnabled}} +

+ {{ctx.Locale.Tr "repo.settings.ai.tier1"}} +

+
+
+ {{.CsrfTokenHtml}} + + +
+
+ + +
+ {{if not .AllowAutoRespond}}{{ctx.Locale.Tr "repo.settings.ai.disabled_by_admin"}}{{end}} +
+ +
+
+ + +
+ {{if not .AllowAutoReview}}{{ctx.Locale.Tr "repo.settings.ai.disabled_by_admin"}}{{end}} +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+ +

+ {{ctx.Locale.Tr "repo.settings.ai.tier2"}} +

+
+
+ {{.CsrfTokenHtml}} + + +
+
+ + +
+ {{if not .AllowAgentMode}}{{ctx.Locale.Tr "repo.settings.ai.disabled_by_admin"}}{{end}} +
+ +
+ + +

{{ctx.Locale.Tr "repo.settings.ai.agent_trigger_labels_desc"}}

+
+ +
+ + +
+ +
+
+ +
+
+
+ +

+ {{ctx.Locale.Tr "repo.settings.ai.escalation"}} +

+
+
+ {{.CsrfTokenHtml}} + + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
+ +

+ {{ctx.Locale.Tr "repo.settings.ai.provider"}} +

+
+
+ {{.CsrfTokenHtml}} + + +
+ + +
+ +
+ + +
+ + {{if .ResolvedProvider}} +
+

{{ctx.Locale.Tr "repo.settings.ai.resolved_provider"}}: {{.ResolvedProvider}} / {{.ResolvedModel}}

+
+ {{end}} + +
+
+ +
+
+
+ +

+ {{ctx.Locale.Tr "repo.settings.ai.instructions"}} +

+
+
+ {{.CsrfTokenHtml}} + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
+ {{end}} + {{end}} +
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 8c76c95cb9..7089450e84 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -101,6 +101,9 @@ {{end}}
+ + {{ctx.Locale.Tr "repo.settings.ai"}} + {{if .EnableMonetize}}
{{ctx.Locale.Tr "repo.settings.subscriptions"}}