From c9f6c4e7d248aeb4010dda4d1f77acf947d1e81a Mon Sep 17 00:00:00 2001 From: logikonline Date: Thu, 12 Feb 2026 00:48:18 -0500 Subject: [PATCH] feat(ui): add ai settings web interface for repos and orgs Add comprehensive web UI for configuring AI features at repository and organization levels, completing the activation workflow for AI operations. Repository AI Settings (repo/settings/ai): - Enable/disable AI unit for the repository - Toggle Tier 1 operations (auto-respond, auto-review, auto-triage, workflow inspection) - Configure Tier 2 agent mode with trigger labels and runtime limits - Set escalation rules (label, team assignment) - Override provider/model preferences - Add custom instructions for different operation types Organization AI Settings (org/settings/ai): - Configure org-level AI provider and model - Set encrypted API key (with masked display) - Define rate limits (max operations per hour) - Whitelist allowed operations - Enable/disable agent mode for org repositories Both interfaces include proper permission checks, form validation, and cascade resolution display (showing inherited vs. overridden values). Adds navigation entries to settings sidebars and full i18n support. --- modules/ai/types.go | 28 ++-- options/locale/locale_en-US.json | 46 ++++++ routers/web/org/setting_ai.go | 96 ++++++++++++ routers/web/repo/setting/ai.go | 220 ++++++++++++++++++++++++++++ routers/web/web.go | 9 ++ services/ai/agent.go | 217 ++++++++++++++++++++++++--- templates/org/settings/ai.tmpl | 77 ++++++++++ templates/org/settings/navbar.tmpl | 3 + templates/repo/settings/ai.tmpl | 208 ++++++++++++++++++++++++++ templates/repo/settings/navbar.tmpl | 3 + 10 files changed, 880 insertions(+), 27 deletions(-) create mode 100644 routers/web/org/setting_ai.go create mode 100644 routers/web/repo/setting/ai.go create mode 100644 templates/org/settings/ai.tmpl create mode 100644 templates/repo/settings/ai.tmpl 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"}}