diff --git a/cmd/cmd.go b/cmd/cmd.go index 4b9bd7c..2a8748c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -50,6 +50,7 @@ func Run(args []string, stdout, stderr io.Writer) int { rootCmd.AddCommand(scanCommand(stdout, stderr, &exitCode)) rootCmd.AddCommand(textCommand(stdout, stderr, &exitCode)) + rootCmd.AddCommand(findConfigCommand(stdout, stderr, &exitCode)) rootCmd.AddCommand(versionCommand(stdout, &exitCode)) rootCmd.SetArgs(args) diff --git a/cmd/find_config.go b/cmd/find_config.go new file mode 100644 index 0000000..a600ad0 --- /dev/null +++ b/cmd/find_config.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/chaoss/disclosure/workflow" + "github.com/spf13/cobra" +) + +func findConfigCommand(stdout, stderr io.Writer, exitCode *int) *cobra.Command { + cmd := &cobra.Command{ + Use: "find-config [repo-path]", + Short: "Detect configured AI settings from GitHub Actions workflows", + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + repoPath := "." + if len(args) > 0 { + repoPath = args[0] + } + + config, err := workflow.DetectConfigs(repoPath) + if err != nil { + fmt.Fprintf(stderr, "error reading workflows: %v\n", err) + *exitCode = ExitError + return err + } + + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(config); err != nil { + fmt.Fprintf(stderr, "error formatting json: %v\n", err) + *exitCode = ExitError + return err + } + + return nil + }, + } + return cmd +} diff --git a/go.mod b/go.mod index fa75ae6..65a4556 100644 --- a/go.mod +++ b/go.mod @@ -29,4 +29,5 @@ require ( golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/workflow/workflow.go b/workflow/workflow.go new file mode 100644 index 0000000..ba723e4 --- /dev/null +++ b/workflow/workflow.go @@ -0,0 +1,103 @@ +package workflow + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type Workflow struct { + Jobs map[string]Job `yaml:"jobs"` +} + +type Job struct { + Steps []Step `yaml:"steps"` +} + +type Step struct { + Uses string `yaml:"uses"` + With map[string]interface{} `yaml:"with"` +} + +// ActionConfig represents the extracted configuration for a single use of the action. +type ActionConfig struct { + Label string `json:"label"` + MinConfidence string `json:"min_confidence"` + ScanPRBody string `json:"scan_pr_body"` +} + +// Config represents the extracted AI configurations from workflow files. +type Config struct { + Configs []ActionConfig `json:"configs"` +} + +func getString(m map[string]interface{}, key, def string) string { + if v, ok := m[key]; ok { + return fmt.Sprintf("%v", v) + } + return def +} + +// DetectConfigs scans the .github/workflows directory for the chaoss/disclosure action +// and returns all configured instances. Default values are populated for missing inputs. +func DetectConfigs(repoPath string) (*Config, error) { + workflowsDir := filepath.Join(repoPath, ".github", "workflows") + entries, err := os.ReadDir(workflowsDir) + if err != nil { + if os.IsNotExist(err) { + return &Config{Configs: []ActionConfig{}}, nil + } + return nil, err + } + + seenConfigs := make(map[ActionConfig]bool) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if ext != ".yml" && ext != ".yaml" { + continue + } + + path := filepath.Join(workflowsDir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var wf Workflow + if err := yaml.Unmarshal(data, &wf); err != nil { + continue + } + + for _, job := range wf.Jobs { + for _, step := range job.Steps { + uses := strings.TrimSpace(step.Uses) + if strings.HasPrefix(uses, "chaoss/disclosure@") || uses == "chaoss/disclosure" { + ac := ActionConfig{ + Label: getString(step.With, "label", "ai-detected"), + MinConfidence: getString(step.With, "min-confidence", "low"), + ScanPRBody: getString(step.With, "scan-pr-body", "true"), + } + seenConfigs[ac] = true + } + } + } + } + + var configs []ActionConfig + for c := range seenConfigs { + configs = append(configs, c) + } + + if configs == nil { + configs = []ActionConfig{} + } + + return &Config{Configs: configs}, nil +} diff --git a/workflow/workflow_test.go b/workflow/workflow_test.go new file mode 100644 index 0000000..364de02 --- /dev/null +++ b/workflow/workflow_test.go @@ -0,0 +1,99 @@ +package workflow + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectConfigs(t *testing.T) { + tempDir := t.TempDir() + workflowsDir := filepath.Join(tempDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatal(err) + } + + yamlContent := ` +jobs: + test: + steps: + - uses: actions/checkout@v4 + - uses: chaoss/disclosure@main + with: + label: custom-ai-label + min-confidence: medium + scan-pr-body: "false" +` + if err := os.WriteFile(filepath.Join(workflowsDir, "test.yml"), []byte(yamlContent), 0644); err != nil { + t.Fatal(err) + } + + config, err := DetectConfigs(tempDir) + if err != nil { + t.Fatal(err) + } + + if len(config.Configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(config.Configs)) + } + + ac := config.Configs[0] + if ac.Label != "custom-ai-label" { + t.Errorf("expected label custom-ai-label, got %v", ac.Label) + } + if ac.MinConfidence != "medium" { + t.Errorf("expected min-confidence medium, got %v", ac.MinConfidence) + } + if ac.ScanPRBody != "false" { + t.Errorf("expected scan-pr-body false, got %v", ac.ScanPRBody) + } +} + +func TestDetectConfigsDefault(t *testing.T) { + tempDir := t.TempDir() + workflowsDir := filepath.Join(tempDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatal(err) + } + + yamlContent := ` +jobs: + test: + steps: + - uses: chaoss/disclosure@v1 +` + if err := os.WriteFile(filepath.Join(workflowsDir, "test.yml"), []byte(yamlContent), 0644); err != nil { + t.Fatal(err) + } + + config, err := DetectConfigs(tempDir) + if err != nil { + t.Fatal(err) + } + + if len(config.Configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(config.Configs)) + } + + ac := config.Configs[0] + if ac.Label != "ai-detected" { + t.Errorf("expected label ai-detected, got %v", ac.Label) + } + if ac.MinConfidence != "low" { + t.Errorf("expected min-confidence low, got %v", ac.MinConfidence) + } + if ac.ScanPRBody != "true" { + t.Errorf("expected scan-pr-body true, got %v", ac.ScanPRBody) + } +} + +func TestDetectConfigsNoWorkflows(t *testing.T) { + tempDir := t.TempDir() + config, err := DetectConfigs(tempDir) + if err != nil { + t.Fatal(err) + } + if len(config.Configs) != 0 { + t.Fatalf("expected empty configs, got %v", config.Configs) + } +}