From d3031d6b73bde51d53e538b79a4a91f295d1a92c Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 1 Feb 2026 06:11:00 +0000 Subject: [PATCH] feat(php): add CI/CD pipeline command (#72) * feat(php): add CI/CD pipeline command Adds `core php ci` command for CI/CD integration: - Runs all QA checks in optimal order (test, stan, psalm, fmt, audit, security) - Generates combined reports in multiple formats: - JSON (--json) for machine consumption - Markdown summary (--summary) for PR comments - SARIF (--sarif) for static analysis tools - Uploads SARIF to GitHub Security tab (--upload-sarif) - Configurable failure threshold (--fail-on=critical|high|warning) Example usage: core php ci # Run full pipeline core php ci --json # Output JSON report core php ci --summary # Output markdown for PR core php ci --sarif --upload-sarif # Generate and upload SARIF Closes #52 Co-Authored-By: Claude Opus 4.5 * fix(php): address CodeRabbit review feedback on CI command - Remove unused --parallel flag - Validate git SHA before SARIF upload - Properly handle and validate SARIF generation output - Exit with correct code when --json flag is used and pipeline fails Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- pkg/i18n/locales/en_GB.json | 9 +- pkg/php/cmd.go | 3 + pkg/php/cmd_ci.go | 562 ++++++++++++++++++++++++++++++++++++ 3 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 pkg/php/cmd_ci.go diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index 7044b1c0..49360550 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -227,7 +227,14 @@ "deploy.short": "Deploy to Coolify", "serve.short": "Run production container", "ssl.short": "Setup SSL certificates with mkcert", - "packages.short": "Manage local PHP packages" + "packages.short": "Manage local PHP packages", + "ci.short": "Run CI/CD pipeline with combined reporting", + "ci.long": "Run all QA checks in optimal order and generate combined reports in JSON, markdown, or SARIF format for CI/CD integration.", + "ci.flag.json": "Output combined JSON report", + "ci.flag.summary": "Output markdown summary (for PR comments)", + "ci.flag.sarif": "Generate SARIF files for static analysis", + "ci.flag.upload_sarif": "Upload SARIF to GitHub Security tab", + "ci.flag.fail_on": "Severity level to fail on (critical, high, warning)" }, "pkg": { "short": "Package management for core-* repos", diff --git a/pkg/php/cmd.go b/pkg/php/cmd.go index 6e1af726..0f72bd92 100644 --- a/pkg/php/cmd.go +++ b/pkg/php/cmd.go @@ -131,6 +131,9 @@ func AddPHPCommands(root *cobra.Command) { addPHPRectorCommand(phpCmd) addPHPInfectionCommand(phpCmd) + // CI/CD Integration + addPHPCICommand(phpCmd) + // Package Management addPHPPackagesCommands(phpCmd) diff --git a/pkg/php/cmd_ci.go b/pkg/php/cmd_ci.go new file mode 100644 index 00000000..3550b12b --- /dev/null +++ b/pkg/php/cmd_ci.go @@ -0,0 +1,562 @@ +// cmd_ci.go implements the 'php ci' command for CI/CD pipeline integration. +// +// Usage: +// core php ci # Run full CI pipeline +// core php ci --json # Output combined JSON report +// core php ci --summary # Output markdown summary +// core php ci --sarif # Generate SARIF files +// core php ci --upload-sarif # Upload SARIF to GitHub Security +// core php ci --fail-on=high # Only fail on high+ severity + +package php + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" + "github.com/spf13/cobra" +) + +// CI command flags +var ( + ciJSON bool + ciSummary bool + ciSARIF bool + ciUploadSARIF bool + ciFailOn string +) + +// CIResult represents the overall CI pipeline result +type CIResult struct { + Passed bool `json:"passed"` + ExitCode int `json:"exit_code"` + Duration string `json:"duration"` + StartedAt time.Time `json:"started_at"` + Checks []CICheckResult `json:"checks"` + Summary CISummary `json:"summary"` + Artifacts []string `json:"artifacts,omitempty"` +} + +// CICheckResult represents an individual check result +type CICheckResult struct { + Name string `json:"name"` + Status string `json:"status"` // passed, failed, warning, skipped + Duration string `json:"duration"` + Details string `json:"details,omitempty"` + Issues int `json:"issues,omitempty"` + Errors int `json:"errors,omitempty"` + Warnings int `json:"warnings,omitempty"` +} + +// CISummary contains aggregate statistics +type CISummary struct { + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Warnings int `json:"warnings"` + Skipped int `json:"skipped"` +} + +func addPHPCICommand(parent *cobra.Command) { + ciCmd := &cobra.Command{ + Use: "ci", + Short: i18n.T("cmd.php.ci.short"), + Long: i18n.T("cmd.php.ci.long"), + RunE: func(cmd *cobra.Command, args []string) error { + return runPHPCI() + }, + } + + ciCmd.Flags().BoolVar(&ciJSON, "json", false, i18n.T("cmd.php.ci.flag.json")) + ciCmd.Flags().BoolVar(&ciSummary, "summary", false, i18n.T("cmd.php.ci.flag.summary")) + ciCmd.Flags().BoolVar(&ciSARIF, "sarif", false, i18n.T("cmd.php.ci.flag.sarif")) + ciCmd.Flags().BoolVar(&ciUploadSARIF, "upload-sarif", false, i18n.T("cmd.php.ci.flag.upload_sarif")) + ciCmd.Flags().StringVar(&ciFailOn, "fail-on", "error", i18n.T("cmd.php.ci.flag.fail_on")) + + parent.AddCommand(ciCmd) +} + +func runPHPCI() error { + cwd, err := os.Getwd() + if err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + if !IsPHPProject(cwd) { + return errors.New(i18n.T("cmd.php.error.not_php")) + } + + startTime := time.Now() + ctx := context.Background() + + // Define checks to run in order + checks := []struct { + name string + run func(context.Context, string) (CICheckResult, error) + sarif bool // Whether this check can generate SARIF + }{ + {"test", runCITest, false}, + {"stan", runCIStan, true}, + {"psalm", runCIPsalm, true}, + {"fmt", runCIFmt, false}, + {"audit", runCIAudit, false}, + {"security", runCISecurity, false}, + } + + result := CIResult{ + StartedAt: startTime, + Passed: true, + Checks: make([]CICheckResult, 0, len(checks)), + } + + var artifacts []string + + // Print header unless JSON output + if !ciJSON { + cli.Print("\n%s\n", cli.BoldStyle.Render("core php ci - QA Pipeline")) + cli.Print("%s\n\n", strings.Repeat("─", 40)) + } + + // Run each check + for _, check := range checks { + if !ciJSON { + cli.Print(" %s %s...", dimStyle.Render("→"), check.name) + } + + checkResult, err := check.run(ctx, cwd) + if err != nil { + checkResult = CICheckResult{ + Name: check.name, + Status: "failed", + Details: err.Error(), + } + } + + result.Checks = append(result.Checks, checkResult) + + // Update summary + result.Summary.Total++ + switch checkResult.Status { + case "passed": + result.Summary.Passed++ + case "failed": + result.Summary.Failed++ + if shouldFailOn(checkResult, ciFailOn) { + result.Passed = false + } + case "warning": + result.Summary.Warnings++ + case "skipped": + result.Summary.Skipped++ + } + + // Print result + if !ciJSON { + cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), check.name, dimStyle.Render(checkResult.Details)) + } + + // Generate SARIF if requested + if (ciSARIF || ciUploadSARIF) && check.sarif { + sarifFile := filepath.Join(cwd, check.name+".sarif") + if generateSARIF(ctx, cwd, check.name, sarifFile) == nil { + artifacts = append(artifacts, sarifFile) + } + } + } + + result.Duration = time.Since(startTime).Round(time.Millisecond).String() + result.Artifacts = artifacts + + // Set exit code + if result.Passed { + result.ExitCode = 0 + } else { + result.ExitCode = 1 + } + + // Output based on flags + if ciJSON { + if err := outputCIJSON(result); err != nil { + return err + } + if !result.Passed { + os.Exit(result.ExitCode) + } + return nil + } + + if ciSummary { + if err := outputCISummary(result); err != nil { + return err + } + if !result.Passed { + return cli.Err("CI pipeline failed") + } + return nil + } + + // Default table output + cli.Print("\n%s\n", strings.Repeat("─", 40)) + + if result.Passed { + cli.Print("%s %s\n", successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration)) + } else { + cli.Print("%s %s\n", errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration)) + } + + if len(artifacts) > 0 { + cli.Print("\n%s\n", dimStyle.Render("Artifacts:")) + for _, a := range artifacts { + cli.Print(" → %s\n", filepath.Base(a)) + } + } + + // Upload SARIF if requested + if ciUploadSARIF && len(artifacts) > 0 { + cli.Blank() + for _, sarifFile := range artifacts { + if err := uploadSARIFToGitHub(ctx, sarifFile); err != nil { + cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), filepath.Base(sarifFile), err) + } else { + cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), filepath.Base(sarifFile)) + } + } + } + + if !result.Passed { + return cli.Err("CI pipeline failed") + } + return nil +} + +// runCITest runs Pest/PHPUnit tests +func runCITest(ctx context.Context, dir string) (CICheckResult, error) { + start := time.Now() + result := CICheckResult{Name: "test", Status: "passed"} + + opts := TestOptions{ + Dir: dir, + Output: nil, // Suppress output + } + + if err := RunTests(ctx, opts); err != nil { + result.Status = "failed" + result.Details = err.Error() + } else { + result.Details = "all tests passed" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIStan runs PHPStan +func runCIStan(ctx context.Context, dir string) (CICheckResult, error) { + start := time.Now() + result := CICheckResult{Name: "stan", Status: "passed"} + + _, found := DetectAnalyser(dir) + if !found { + result.Status = "skipped" + result.Details = "PHPStan not configured" + return result, nil + } + + opts := AnalyseOptions{ + Dir: dir, + Output: nil, + } + + if err := Analyse(ctx, opts); err != nil { + result.Status = "failed" + result.Details = "errors found" + } else { + result.Details = "0 errors" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIPsalm runs Psalm +func runCIPsalm(ctx context.Context, dir string) (CICheckResult, error) { + start := time.Now() + result := CICheckResult{Name: "psalm", Status: "passed"} + + _, found := DetectPsalm(dir) + if !found { + result.Status = "skipped" + result.Details = "Psalm not configured" + return result, nil + } + + opts := PsalmOptions{ + Dir: dir, + Output: nil, + } + + if err := RunPsalm(ctx, opts); err != nil { + result.Status = "failed" + result.Details = "errors found" + } else { + result.Details = "0 errors" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIFmt checks code formatting +func runCIFmt(ctx context.Context, dir string) (CICheckResult, error) { + start := time.Now() + result := CICheckResult{Name: "fmt", Status: "passed"} + + _, found := DetectFormatter(dir) + if !found { + result.Status = "skipped" + result.Details = "no formatter configured" + return result, nil + } + + opts := FormatOptions{ + Dir: dir, + Fix: false, // Check only + Output: nil, + } + + if err := Format(ctx, opts); err != nil { + result.Status = "warning" + result.Details = "formatting issues" + } else { + result.Details = "code style OK" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCIAudit runs composer audit +func runCIAudit(ctx context.Context, dir string) (CICheckResult, error) { + start := time.Now() + result := CICheckResult{Name: "audit", Status: "passed"} + + results, err := RunAudit(ctx, AuditOptions{ + Dir: dir, + Output: nil, + }) + if err != nil { + result.Status = "failed" + result.Details = err.Error() + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil + } + + totalVulns := 0 + for _, r := range results { + totalVulns += r.Vulnerabilities + } + + if totalVulns > 0 { + result.Status = "failed" + result.Details = fmt.Sprintf("%d vulnerabilities", totalVulns) + result.Issues = totalVulns + } else { + result.Details = "no vulnerabilities" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// runCISecurity runs security checks +func runCISecurity(ctx context.Context, dir string) (CICheckResult, error) { + start := time.Now() + result := CICheckResult{Name: "security", Status: "passed"} + + secResult, err := RunSecurityChecks(ctx, SecurityOptions{ + Dir: dir, + Output: nil, + }) + if err != nil { + result.Status = "failed" + result.Details = err.Error() + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil + } + + if secResult.Summary.Critical > 0 || secResult.Summary.High > 0 { + result.Status = "failed" + result.Details = fmt.Sprintf("%d critical, %d high", secResult.Summary.Critical, secResult.Summary.High) + result.Issues = secResult.Summary.Critical + secResult.Summary.High + } else if secResult.Summary.Medium > 0 { + result.Status = "warning" + result.Details = fmt.Sprintf("%d medium issues", secResult.Summary.Medium) + result.Warnings = secResult.Summary.Medium + } else { + result.Details = "no issues" + } + + result.Duration = time.Since(start).Round(time.Millisecond).String() + return result, nil +} + +// shouldFailOn determines if a check should cause CI failure based on --fail-on +func shouldFailOn(check CICheckResult, level string) bool { + switch level { + case "critical": + return check.Status == "failed" && check.Issues > 0 + case "high", "error": + return check.Status == "failed" + case "warning": + return check.Status == "failed" || check.Status == "warning" + default: + return check.Status == "failed" + } +} + +// getStatusIcon returns the icon for a check status +func getStatusIcon(status string) string { + switch status { + case "passed": + return successStyle.Render("✓") + case "failed": + return errorStyle.Render("✗") + case "warning": + return phpQAWarningStyle.Render("⚠") + case "skipped": + return dimStyle.Render("-") + default: + return dimStyle.Render("?") + } +} + +// outputCIJSON outputs the result as JSON +func outputCIJSON(result CIResult) error { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// outputCISummary outputs a markdown summary +func outputCISummary(result CIResult) error { + var sb strings.Builder + + sb.WriteString("## CI Pipeline Results\n\n") + + if result.Passed { + sb.WriteString("**Status:** ✅ Passed\n\n") + } else { + sb.WriteString("**Status:** ❌ Failed\n\n") + } + + sb.WriteString("| Check | Status | Details |\n") + sb.WriteString("|-------|--------|----------|\n") + + for _, check := range result.Checks { + icon := "✅" + switch check.Status { + case "failed": + icon = "❌" + case "warning": + icon = "⚠️" + case "skipped": + icon = "⏭️" + } + sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", check.Name, icon, check.Details)) + } + + sb.WriteString(fmt.Sprintf("\n**Duration:** %s\n", result.Duration)) + + fmt.Print(sb.String()) + return nil +} + +// generateSARIF generates a SARIF file for a specific check +func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error { + var args []string + + switch checkName { + case "stan": + args = []string{"vendor/bin/phpstan", "analyse", "--error-format=sarif", "--no-progress"} + case "psalm": + args = []string{"vendor/bin/psalm", "--output-format=sarif"} + default: + return fmt.Errorf("SARIF not supported for %s", checkName) + } + + cmd := exec.CommandContext(ctx, "php", args...) + cmd.Dir = dir + + // Capture output - command may exit non-zero when issues are found + // but still produce valid SARIF output + output, err := cmd.CombinedOutput() + if len(output) == 0 { + if err != nil { + return fmt.Errorf("failed to generate SARIF: %w", err) + } + return fmt.Errorf("no SARIF output generated") + } + + // Validate output is valid JSON + var js json.RawMessage + if err := json.Unmarshal(output, &js); err != nil { + return fmt.Errorf("invalid SARIF output: %w", err) + } + + return os.WriteFile(outputFile, output, 0644) +} + +// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab +func uploadSARIFToGitHub(ctx context.Context, sarifFile string) error { + // Validate commit SHA before calling API + sha := getGitSHA() + if sha == "" { + return errors.New("cannot upload SARIF: git commit SHA not available (ensure you're in a git repository)") + } + + // Use gh CLI to upload + cmd := exec.CommandContext(ctx, "gh", "api", + "repos/{owner}/{repo}/code-scanning/sarifs", + "-X", "POST", + "-F", "sarif=@"+sarifFile, + "-F", "ref="+getGitRef(), + "-F", "commit_sha="+sha, + ) + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %s", err, string(output)) + } + return nil +} + +// getGitRef returns the current git ref +func getGitRef() string { + cmd := exec.Command("git", "symbolic-ref", "HEAD") + output, err := cmd.Output() + if err != nil { + return "refs/heads/main" + } + return strings.TrimSpace(string(output)) +} + +// getGitSHA returns the current git commit SHA +func getGitSHA() string { + cmd := exec.Command("git", "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +}