Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/preq/preq.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
)

var vars = kong.Vars{
"actionHelp": ux.HelpAction,
"disabledHelp": ux.HelpDisabled,
"generateHelp": ux.HelpGenerate,
"cronHelp": ux.HelpCron,
Expand Down
26 changes: 11 additions & 15 deletions internal/pkg/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/prequel-dev/preq/internal/pkg/engine"
"github.com/prequel-dev/preq/internal/pkg/resolve"
"github.com/prequel-dev/preq/internal/pkg/rules"
"github.com/prequel-dev/preq/internal/pkg/runbook"
"github.com/prequel-dev/preq/internal/pkg/timez"
"github.com/prequel-dev/preq/internal/pkg/utils"
"github.com/prequel-dev/preq/internal/pkg/ux"
Expand All @@ -20,6 +21,7 @@ import (
)

var Options struct {
Action string `short:"a" help:"${actionHelp}"`
Disabled bool `short:"d" help:"${disabledHelp}"`
Generate bool `short:"g" help:"${generateHelp}"`
Cron bool `short:"j" help:"${cronHelp}"`
Expand Down Expand Up @@ -263,26 +265,20 @@ LOOP:
log.Debug().Msg("No CREs found")
return nil

case c.Notification.Type == ux.NotificationSlack:
case Options.Action != "":
log.Debug().Str("path", Options.Action).Msg("Running action")

log.Debug().Msgf("Posting Slack notification to %s", c.Notification.Webhook)

if err = report.PostSlackDetection(ctx, c.Notification.Webhook, Options.Name); err != nil {
log.Error().Err(err).Msg("Failed to post Slack notification")
report, err := report.CreateReport()
if err != nil {
log.Error().Err(err).Msg("Failed to create report")
ux.RulesError(err)
return err
}

if !Options.Quiet {

// Print reports to stdout when notifications are enabled
if err = report.PrintReport(); err != nil {
log.Error().Err(err).Msg("Failed to print report")
ux.RulesError(err)
return err
}

fmt.Fprintf(os.Stdout, "\nSent Slack notification\n")
if err := runbook.Runbook(ctx, Options.Action, report); err != nil {
log.Error().Err(err).Msg("Failed to run action")
ux.RulesError(err)
return err
}

case Options.Name == ux.OutputStdout:
Expand Down
22 changes: 8 additions & 14 deletions internal/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,21 +175,15 @@ var (
windowConfig = `window: %s`
)

type NotificationWebhook struct {
Type string `yaml:"type"`
Webhook string `yaml:"webhook"`
}

type Config struct {
TimestampRegexes []Regex `yaml:"timestamps"`
Rules Rules `yaml:"rules"`
UpdateFrequency *time.Duration `yaml:"updateFrequency"`
RulesVersion string `yaml:"rulesVersion"`
AcceptUpdates bool `yaml:"acceptUpdates"`
DataSources string `yaml:"dataSources"`
Notification NotificationWebhook `yaml:"notification"`
Window time.Duration `yaml:"window"`
Skip int `yaml:"skip"`
TimestampRegexes []Regex `yaml:"timestamps"`
Rules Rules `yaml:"rules"`
UpdateFrequency *time.Duration `yaml:"updateFrequency"`
RulesVersion string `yaml:"rulesVersion"`
AcceptUpdates bool `yaml:"acceptUpdates"`
DataSources string `yaml:"dataSources"`
Window time.Duration `yaml:"window"`
Skip int `yaml:"skip"`
}

type Rules struct {
Expand Down
51 changes: 51 additions & 0 deletions internal/pkg/runbook/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package runbook

import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"text/template"
)

type execConfig struct {
Path string `yaml:"path"`
Args []string `yaml:"args"`
}

type execAction struct {
cfg execConfig
}

func newExecAction(cfg execConfig) (Action, error) {
if cfg.Path == "" {
return nil, errors.New("exec.path is required")
}
return &execAction{cfg: cfg}, nil
}

func (e *execAction) Execute(ctx context.Context, cre map[string]any) error {
args := make([]string, len(e.cfg.Args))
for i, a := range e.cfg.Args {
tmpl, err := template.New("arg").Funcs(funcMap()).Parse(a)
if err != nil {
return err
}
if err := executeTemplate(&args[i], tmpl, cre); err != nil {
return err
}
}

raw, err := json.Marshal(cre)
if err != nil {
return err
}

cmd := exec.CommandContext(ctx, e.cfg.Path, args...)
cmd.Stdin = bytes.NewReader(raw)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
121 changes: 121 additions & 0 deletions internal/pkg/runbook/jira.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package runbook

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"text/template"
"time"
)

type jiraConfig struct {
WebhookURL string `yaml:"webhook_url"`
Secret string `yaml:"secret"` // optional
SecretEnv string `yaml:"secret_env"` // optional
SummaryTemplate string `yaml:"summary_template"`
DescriptionTemplate string `yaml:"description_template"`
ProjectKey string `yaml:"project_key"` // e.g. "PREQ"
}

type jiraAction struct {
cfg jiraConfig
summaryTmpl *template.Template
descTmpl *template.Template
httpc *http.Client
}

func newJiraAction(cfg jiraConfig) (Action, error) {
if cfg.WebhookURL == "" {
return nil, errors.New("jira.webhook_url is required")
}
if cfg.SummaryTemplate == "" {
return nil, errors.New("jira.summary_template is required")
}
if cfg.ProjectKey == "" {
return nil, errors.New("jira.project_key is required when using REST API mode")
}
st, err := template.New("jira-summary").Funcs(funcMap()).Parse(cfg.SummaryTemplate)
if err != nil {
return nil, err
}
dt, err := template.New("jira-desc").Funcs(funcMap()).Parse(cfg.DescriptionTemplate)
if err != nil {
return nil, err
}

if cfg.Secret == "" && cfg.SecretEnv != "" {
cfg.Secret = os.Getenv(cfg.SecretEnv)
}
// optional: hard‑fail if both were empty
if cfg.Secret == "" {
return nil, errors.New("jira secret missing; set either 'secret' or 'secret_env'")
}

return &jiraAction{
cfg: cfg,
summaryTmpl: st,
descTmpl: dt,
httpc: &http.Client{
Timeout: 5 * time.Second,
},
}, nil
}

func (j *jiraAction) Execute(ctx context.Context, cre map[string]any) error {
var summary, desc string
if err := executeTemplate(&summary, j.summaryTmpl, cre); err != nil {
return err
}
if err := executeTemplate(&desc, j.descTmpl, cre); err != nil {
return err
}
payload := map[string]any{
"project": map[string]any{"key": j.cfg.ProjectKey},
"summary": summary,
"description": adfParagraph(desc),
"issuetype": map[string]any{"name": "Bug"},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.cfg.WebhookURL,
bytes.NewReader(body))
if err != nil {
return fmt.Errorf("jira post: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if j.cfg.Secret != "" {
req.Header.Set("X-Automation-Webhook-Token", j.cfg.Secret)
}
resp, err := j.httpc.Do(req)
if err != nil {
return fmt.Errorf("jira post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("jira post failed: %s – %s", resp.Status, respBody)
}
return nil
}

func adfParagraph(txt string) map[string]any {
return map[string]any{
"type": "doc",
"version": 1,
"content": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{
"type": "text",
"text": txt,
},
},
},
},
}
}
Loading