2026-09-02 19:21:30 -07:00
|
|
|
|
package action
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"os/exec"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// NotifyStatusInput is input for NotifyStatusActivity
|
|
|
|
|
|
type NotifyStatusInput struct {
|
|
|
|
|
|
Channel string `json:"channel"` // slack, email, webhook
|
|
|
|
|
|
Status string `json:"status"` // success, failure, warning
|
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NotifyStatusOutput is output from NotifyStatusActivity
|
|
|
|
|
|
type NotifyStatusOutput struct {
|
|
|
|
|
|
NotificationID string `json:"notificationId"`
|
|
|
|
|
|
Timestamp string `json:"timestamp"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NotifyStatusActivity sends notifications
|
|
|
|
|
|
func NotifyStatusActivity(ctx context.Context, in NotifyStatusInput) (NotifyStatusOutput, error) {
|
|
|
|
|
|
logger := newActivityLogger(ctx)
|
|
|
|
|
|
logger.Info("NotifyStatusActivity started", "channel", in.Channel, "status", in.Status)
|
|
|
|
|
|
|
|
|
|
|
|
timestamp := time.Now().Format(time.RFC3339)
|
|
|
|
|
|
notificationID := fmt.Sprintf("notify-%d", time.Now().UnixNano())
|
|
|
|
|
|
|
|
|
|
|
|
switch in.Channel {
|
|
|
|
|
|
case "slack":
|
|
|
|
|
|
if err := sendSlackNotification(ctx, in); err != nil {
|
|
|
|
|
|
logger.Warn("Slack notification failed", "error", err)
|
|
|
|
|
|
// Don't fail the activity, just log
|
|
|
|
|
|
}
|
|
|
|
|
|
case "webhook":
|
|
|
|
|
|
if err := sendWebhookNotification(ctx, in); err != nil {
|
|
|
|
|
|
logger.Warn("Webhook notification failed", "error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
case "email":
|
|
|
|
|
|
// Email would require SMTP setup - log for now
|
|
|
|
|
|
logger.Info("Email notification (logged)", "message", in.Message)
|
|
|
|
|
|
default:
|
|
|
|
|
|
logger.Info("Notification logged", "channel", in.Channel, "message", in.Message)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logger.Info("NotifyStatusActivity completed", "notificationId", notificationID)
|
|
|
|
|
|
return NotifyStatusOutput{
|
|
|
|
|
|
NotificationID: notificationID,
|
|
|
|
|
|
Timestamp: timestamp,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func sendSlackNotification(ctx context.Context, in NotifyStatusInput) error {
|
|
|
|
|
|
webhookURL := os.Getenv("SLACK_WEBHOOK_URL")
|
|
|
|
|
|
if webhookURL == "" {
|
|
|
|
|
|
return fmt.Errorf("SLACK_WEBHOOK_URL not set")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Map status to emoji
|
|
|
|
|
|
emoji := "ℹ️"
|
|
|
|
|
|
switch in.Status {
|
|
|
|
|
|
case "success":
|
|
|
|
|
|
emoji = "✅"
|
|
|
|
|
|
case "failure":
|
|
|
|
|
|
emoji = "❌"
|
|
|
|
|
|
case "warning":
|
|
|
|
|
|
emoji = "⚠️"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := map[string]string{
|
|
|
|
|
|
"text": fmt.Sprintf("%s *%s*: %s", emoji, in.Status, in.Message),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(payload)
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
return fmt.Errorf("slack returned status %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func sendWebhookNotification(ctx context.Context, in NotifyStatusInput) error {
|
|
|
|
|
|
webhookURL := os.Getenv("NOTIFICATION_WEBHOOK_URL")
|
|
|
|
|
|
if webhookURL == "" {
|
|
|
|
|
|
return fmt.Errorf("NOTIFICATION_WEBHOOK_URL not set")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
|
"channel": in.Channel,
|
|
|
|
|
|
"status": in.Status,
|
|
|
|
|
|
"message": in.Message,
|
|
|
|
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body, _ := json.Marshal(payload)
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode >= 400 {
|
|
|
|
|
|
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ArchiveResultsInput is input for ArchiveResultsActivity
|
|
|
|
|
|
type ArchiveResultsInput struct {
|
|
|
|
|
|
ReportPath string `json:"reportPath"`
|
|
|
|
|
|
Destination string `json:"destination"` // s3://bucket/path or local path
|
|
|
|
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ArchiveResultsOutput is output from ArchiveResultsActivity
|
|
|
|
|
|
type ArchiveResultsOutput struct {
|
|
|
|
|
|
ArchiveURL string `json:"archiveUrl"`
|
|
|
|
|
|
ArchiveSize int64 `json:"archiveSize"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ArchiveResultsActivity archives results to storage
|
|
|
|
|
|
func ArchiveResultsActivity(ctx context.Context, in ArchiveResultsInput) (ArchiveResultsOutput, error) {
|
|
|
|
|
|
logger := newActivityLogger(ctx)
|
|
|
|
|
|
logger.Info("ArchiveResultsActivity started", "reportPath", in.ReportPath, "destination", in.Destination)
|
|
|
|
|
|
|
|
|
|
|
|
// Check if source exists
|
|
|
|
|
|
info, err := os.Stat(in.ReportPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ArchiveResultsOutput{}, fmt.Errorf("report not found: %s", in.ReportPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For now, just copy to local destination or log for cloud
|
|
|
|
|
|
var archiveURL string
|
|
|
|
|
|
var archiveSize int64
|
|
|
|
|
|
|
|
|
|
|
|
if len(in.Destination) > 5 && in.Destination[:5] == "s3://" {
|
|
|
|
|
|
// Would use AWS SDK - for now just log
|
|
|
|
|
|
logger.Info("Would upload to S3", "destination", in.Destination)
|
|
|
|
|
|
archiveURL = in.Destination
|
|
|
|
|
|
archiveSize = info.Size()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Copy to local destination
|
|
|
|
|
|
content, err := os.ReadFile(in.ReportPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ArchiveResultsOutput{}, fmt.Errorf("failed to read report: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
destPath := in.Destination
|
|
|
|
|
|
if destPath == "" {
|
|
|
|
|
|
destPath = fmt.Sprintf("/tmp/archive-%d", time.Now().UnixNano())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
|
|
|
|
|
return ArchiveResultsOutput{}, fmt.Errorf("failed to write archive: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
archiveURL = destPath
|
|
|
|
|
|
archiveSize = int64(len(content))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logger.Info("ArchiveResultsActivity completed", "archiveUrl", archiveURL, "size", archiveSize)
|
|
|
|
|
|
return ArchiveResultsOutput{
|
|
|
|
|
|
ArchiveURL: archiveURL,
|
|
|
|
|
|
ArchiveSize: archiveSize,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DeploymentPreCheckInput is input for DeploymentPreCheckActivity
|
|
|
|
|
|
type DeploymentPreCheckInput struct {
|
|
|
|
|
|
Path string `json:"path"`
|
|
|
|
|
|
CheckType string `json:"checkType,omitempty"` // lint, test, build, all
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DeploymentPreCheckOutput is output from DeploymentPreCheckActivity
|
|
|
|
|
|
type DeploymentPreCheckOutput struct {
|
|
|
|
|
|
Passed bool `json:"passed"`
|
|
|
|
|
|
Failures []string `json:"failures"`
|
|
|
|
|
|
Warnings []string `json:"warnings"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 08:50:02 -07:00
|
|
|
|
// checker defines a deployment check
|
|
|
|
|
|
type checker struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
types []string // which checkTypes trigger this checker
|
|
|
|
|
|
isWarning bool // if true, failure goes to warnings not failures
|
|
|
|
|
|
run func(ctx context.Context, path string) (string, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// shouldRun checks if this checker should run for given checkType
|
|
|
|
|
|
func (c *checker) shouldRun(checkType string) bool {
|
|
|
|
|
|
for _, t := range c.types {
|
|
|
|
|
|
if t == checkType {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// goCheckers returns checkers for Go projects
|
|
|
|
|
|
func goCheckers() []*checker {
|
|
|
|
|
|
return []*checker{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "build",
|
|
|
|
|
|
types: []string{"all", "build"},
|
|
|
|
|
|
run: func(ctx context.Context, path string) (string, error) {
|
|
|
|
|
|
cmd := exec.CommandContext(ctx, "go", "build", "./...")
|
|
|
|
|
|
cmd.Dir = path
|
|
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
|
|
return string(out), err
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "test",
|
|
|
|
|
|
types: []string{"all", "test"},
|
|
|
|
|
|
run: func(ctx context.Context, path string) (string, error) {
|
|
|
|
|
|
cmd := exec.CommandContext(ctx, "go", "test", "-short", "./...")
|
|
|
|
|
|
cmd.Dir = path
|
|
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
|
|
return string(out), err
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "lint",
|
|
|
|
|
|
types: []string{"all", "lint"},
|
|
|
|
|
|
isWarning: true,
|
|
|
|
|
|
run: func(ctx context.Context, path string) (string, error) {
|
|
|
|
|
|
cmd := exec.CommandContext(ctx, "go", "vet", "./...")
|
|
|
|
|
|
cmd.Dir = path
|
|
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
|
|
|
|
return string(out), err
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// detectProjectCheckers returns checkers based on project type
|
|
|
|
|
|
func detectProjectCheckers(path string) []*checker {
|
|
|
|
|
|
if _, err := os.Stat(fmt.Sprintf("%s/go.mod", path)); err == nil {
|
|
|
|
|
|
return goCheckers()
|
|
|
|
|
|
}
|
|
|
|
|
|
// Add more project types here (Node, Python, etc.)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 19:21:30 -07:00
|
|
|
|
// DeploymentPreCheckActivity validates deployment readiness
|
|
|
|
|
|
func DeploymentPreCheckActivity(ctx context.Context, in DeploymentPreCheckInput) (DeploymentPreCheckOutput, error) {
|
|
|
|
|
|
logger := newActivityLogger(ctx)
|
|
|
|
|
|
logger.Info("DeploymentPreCheckActivity started", "path", in.Path, "checkType", in.CheckType)
|
|
|
|
|
|
|
|
|
|
|
|
output := DeploymentPreCheckOutput{
|
|
|
|
|
|
Passed: true,
|
|
|
|
|
|
Failures: []string{},
|
|
|
|
|
|
Warnings: []string{},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
checkType := in.CheckType
|
|
|
|
|
|
if checkType == "" {
|
|
|
|
|
|
checkType = "all"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if path exists
|
|
|
|
|
|
if _, err := os.Stat(in.Path); os.IsNotExist(err) {
|
|
|
|
|
|
output.Passed = false
|
|
|
|
|
|
output.Failures = append(output.Failures, fmt.Sprintf("path does not exist: %s", in.Path))
|
|
|
|
|
|
return output, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 08:50:02 -07:00
|
|
|
|
// Run applicable checkers
|
|
|
|
|
|
for _, c := range detectProjectCheckers(in.Path) {
|
|
|
|
|
|
if !c.shouldRun(checkType) {
|
|
|
|
|
|
continue
|
2026-09-02 19:21:30 -07:00
|
|
|
|
}
|
2026-09-03 08:50:02 -07:00
|
|
|
|
if out, err := c.run(ctx, in.Path); err != nil {
|
|
|
|
|
|
msg := fmt.Sprintf("%s failed: %s", c.name, out)
|
|
|
|
|
|
if c.isWarning {
|
|
|
|
|
|
output.Warnings = append(output.Warnings, msg)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
output.Passed = false
|
|
|
|
|
|
output.Failures = append(output.Failures, msg)
|
|
|
|
|
|
}
|
2026-09-02 19:21:30 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logger.Info("DeploymentPreCheckActivity completed", "passed", output.Passed, "failures", len(output.Failures))
|
|
|
|
|
|
return output, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ApproveWorkflowInput is input for ApproveWorkflowActivity
|
|
|
|
|
|
type ApproveWorkflowInput struct {
|
|
|
|
|
|
WorkflowID string `json:"workflowId"`
|
|
|
|
|
|
RequiredApprovals int `json:"requiredApprovals,omitempty"`
|
|
|
|
|
|
TimeoutMinutes int `json:"timeoutMinutes,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ApproveWorkflowOutput is output from ApproveWorkflowActivity
|
|
|
|
|
|
type ApproveWorkflowOutput struct {
|
|
|
|
|
|
Approved bool `json:"approved"`
|
|
|
|
|
|
Approver string `json:"approver,omitempty"`
|
|
|
|
|
|
Timestamp string `json:"timestamp"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ApproveWorkflowActivity handles approval workflow (auto-approves for now)
|
|
|
|
|
|
func ApproveWorkflowActivity(ctx context.Context, in ApproveWorkflowInput) (ApproveWorkflowOutput, error) {
|
|
|
|
|
|
logger := newActivityLogger(ctx)
|
|
|
|
|
|
logger.Info("ApproveWorkflowActivity started", "workflowId", in.WorkflowID)
|
|
|
|
|
|
|
|
|
|
|
|
// For now, auto-approve
|
|
|
|
|
|
// In production, this would wait for human approval via signal or external system
|
|
|
|
|
|
|
|
|
|
|
|
timestamp := time.Now().Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
|
|
logger.Info("ApproveWorkflowActivity completed (auto-approved)")
|
|
|
|
|
|
return ApproveWorkflowOutput{
|
|
|
|
|
|
Approved: true,
|
|
|
|
|
|
Approver: "system-auto",
|
|
|
|
|
|
Timestamp: timestamp,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|