// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package v2 import ( "encoding/json" "time" "code.gitea.io/gitea/modules/actions" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" ) // getSupportedActions converts the compatibility module data to API format func getSupportedActions() map[string]api.ActionSupport { result := make(map[string]api.ActionSupport) for name, compat := range actions.BuiltinCompatibility { result[name] = api.ActionSupport{ Versions: compat.Versions, Notes: compat.Notes, } } return result } // GetActionsCapabilities returns structured capability information for AI consumption // @Summary Get runner capabilities for AI workflow generation // @Description Returns detailed runner capabilities, platform info, and action compatibility // @Tags actions // @Accept json // @Produce json // @Param owner path string true "owner of the repo" // @Param repo path string true "name of the repo" // @Success 200 {object} api.ActionsCapabilitiesResponse // @Router /repos/{owner}/{repo}/actions/runners/capabilities [get] func GetActionsCapabilities(ctx *context.APIContext) { repo := ctx.Repo.Repository // Get runners available for this repository runners, err := actions_model.GetRunnersOfRepo(ctx, repo.ID) if err != nil { ctx.APIErrorInternal(err) return } // Build response response := &api.ActionsCapabilitiesResponse{ Runners: make([]*api.RunnerWithCapabilities, 0, len(runners)), Platform: &api.PlatformInfo{ Type: "gitea", Version: setting.AppVer, DefaultActionsURL: setting.Actions.DefaultActionsURL.URL(), SupportedActions: getSupportedActions(), UnsupportedFeatures: actions.UnsupportedFeatures, }, WorkflowHints: &api.WorkflowHints{ PreferredCheckout: "actions/checkout@v4", ArtifactUploadAlternative: "Use Gitea API: curl -X POST $GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/{id}/assets", SecretAccess: "Use ${{ secrets.NAME }} syntax", }, } // Process each runner for _, runner := range runners { status := "offline" if runner.LastOnline.AsTime().Add(actions_model.RunnerOfflineTime).After(time.Now()) { status = "online" } runnerResp := &api.RunnerWithCapabilities{ ID: runner.ID, Name: runner.Name, Status: status, Labels: runner.AgentLabels, } // Parse capabilities JSON if available if runner.CapabilitiesJSON != "" { var cap api.RunnerCapability if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &cap); err == nil { runnerResp.Capabilities = &cap } } // If no capabilities, infer from labels if runnerResp.Capabilities == nil { runnerResp.Capabilities = inferCapabilitiesFromLabels(runner.AgentLabels) } response.Runners = append(response.Runners, runnerResp) } ctx.JSON(200, response) } // inferCapabilitiesFromLabels attempts to infer capabilities from runner labels func inferCapabilitiesFromLabels(labels []string) *api.RunnerCapability { cap := &api.RunnerCapability{ Limitations: []string{ "Capabilities inferred from labels - may not be accurate", "actions/upload-artifact@v4 not supported (use v3 or direct API upload)", }, } for _, label := range labels { switch label { case "ubuntu-latest", "ubuntu-22.04", "ubuntu-20.04": cap.OS = "linux" cap.Shell = []string{"bash", "sh"} case "windows-latest", "windows-2022", "windows-2019": cap.OS = "windows" cap.Shell = []string{"pwsh", "powershell", "cmd"} case "macos-latest", "macos-13", "macos-12": cap.OS = "darwin" cap.Shell = []string{"bash", "sh", "zsh"} case "linux": cap.OS = "linux" case "x64", "amd64": cap.Arch = "amd64" case "arm64", "aarch64": cap.Arch = "arm64" case "docker": cap.Docker = true cap.ContainerRuntime = "docker" } } return cap } // ValidateWorkflow validates a workflow YAML and returns compatibility warnings // @Summary Validate a workflow for compatibility // @Description Parses workflow YAML and returns warnings about unsupported features // @Tags actions // @Accept json // @Produce json // @Param owner path string true "owner of the repo" // @Param repo path string true "name of the repo" // @Param body body api.WorkflowValidationRequest true "Workflow content" // @Success 200 {object} api.WorkflowValidationResponse // @Router /repos/{owner}/{repo}/actions/workflows/validate [post] func ValidateWorkflow(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.WorkflowValidationRequest) response := &api.WorkflowValidationResponse{ Valid: true, Warnings: make([]*api.WorkflowValidationWarning, 0), } // Check for known incompatible actions using the compatibility module for action, message := range actions.IncompatibleActions { if containsAction(form.Content, action) { response.Warnings = append(response.Warnings, &api.WorkflowValidationWarning{ Action: action, Severity: "error", Message: message, Suggestion: actions.GetSuggestedAlternative(action), }) } } if len(response.Warnings) > 0 { response.Valid = false } ctx.JSON(200, response) } // containsAction checks if workflow content contains a specific action func containsAction(content, action string) bool { // Simple string search - could be enhanced with YAML parsing return len(content) > 0 && len(action) > 0 && (contains(content, "uses: "+action) || contains(content, "uses: \""+action+"\"")) } func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) } func containsSubstr(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false }