Skip to content

Commit 96d0f09

Browse files
authored
Tony/actions (#46)
* Update action * Break apart code * Update comments * remove old slack notification code * PR feedback --------- Co-authored-by: Tony Meehan <[email protected]>
1 parent f13987d commit 96d0f09

File tree

9 files changed

+487
-104
lines changed

9 files changed

+487
-104
lines changed

cmd/preq/preq.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
)
1515

1616
var vars = kong.Vars{
17+
"actionHelp": ux.HelpAction,
1718
"disabledHelp": ux.HelpDisabled,
1819
"generateHelp": ux.HelpGenerate,
1920
"cronHelp": ux.HelpCron,

internal/pkg/cli/cli.go

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/prequel-dev/preq/internal/pkg/engine"
1313
"github.com/prequel-dev/preq/internal/pkg/resolve"
1414
"github.com/prequel-dev/preq/internal/pkg/rules"
15+
"github.com/prequel-dev/preq/internal/pkg/runbook"
1516
"github.com/prequel-dev/preq/internal/pkg/timez"
1617
"github.com/prequel-dev/preq/internal/pkg/utils"
1718
"github.com/prequel-dev/preq/internal/pkg/ux"
@@ -20,6 +21,7 @@ import (
2021
)
2122

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

266-
case c.Notification.Type == ux.NotificationSlack:
268+
case Options.Action != "":
269+
log.Debug().Str("path", Options.Action).Msg("Running action")
267270

268-
log.Debug().Msgf("Posting Slack notification to %s", c.Notification.Webhook)
269-
270-
if err = report.PostSlackDetection(ctx, c.Notification.Webhook, Options.Name); err != nil {
271-
log.Error().Err(err).Msg("Failed to post Slack notification")
271+
report, err := report.CreateReport()
272+
if err != nil {
273+
log.Error().Err(err).Msg("Failed to create report")
272274
ux.RulesError(err)
273275
return err
274276
}
275277

276-
if !Options.Quiet {
277-
278-
// Print reports to stdout when notifications are enabled
279-
if err = report.PrintReport(); err != nil {
280-
log.Error().Err(err).Msg("Failed to print report")
281-
ux.RulesError(err)
282-
return err
283-
}
284-
285-
fmt.Fprintf(os.Stdout, "\nSent Slack notification\n")
278+
if err := runbook.Runbook(ctx, Options.Action, report); err != nil {
279+
log.Error().Err(err).Msg("Failed to run action")
280+
ux.RulesError(err)
281+
return err
286282
}
287283

288284
case Options.Name == ux.OutputStdout:

internal/pkg/config/config.go

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -175,21 +175,15 @@ var (
175175
windowConfig = `window: %s`
176176
)
177177

178-
type NotificationWebhook struct {
179-
Type string `yaml:"type"`
180-
Webhook string `yaml:"webhook"`
181-
}
182-
183178
type Config struct {
184-
TimestampRegexes []Regex `yaml:"timestamps"`
185-
Rules Rules `yaml:"rules"`
186-
UpdateFrequency *time.Duration `yaml:"updateFrequency"`
187-
RulesVersion string `yaml:"rulesVersion"`
188-
AcceptUpdates bool `yaml:"acceptUpdates"`
189-
DataSources string `yaml:"dataSources"`
190-
Notification NotificationWebhook `yaml:"notification"`
191-
Window time.Duration `yaml:"window"`
192-
Skip int `yaml:"skip"`
179+
TimestampRegexes []Regex `yaml:"timestamps"`
180+
Rules Rules `yaml:"rules"`
181+
UpdateFrequency *time.Duration `yaml:"updateFrequency"`
182+
RulesVersion string `yaml:"rulesVersion"`
183+
AcceptUpdates bool `yaml:"acceptUpdates"`
184+
DataSources string `yaml:"dataSources"`
185+
Window time.Duration `yaml:"window"`
186+
Skip int `yaml:"skip"`
193187
}
194188

195189
type Rules struct {

internal/pkg/runbook/exec.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package runbook
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"os"
9+
"os/exec"
10+
"text/template"
11+
)
12+
13+
type execConfig struct {
14+
Path string `yaml:"path"`
15+
Args []string `yaml:"args"`
16+
}
17+
18+
type execAction struct {
19+
cfg execConfig
20+
}
21+
22+
func newExecAction(cfg execConfig) (Action, error) {
23+
if cfg.Path == "" {
24+
return nil, errors.New("exec.path is required")
25+
}
26+
return &execAction{cfg: cfg}, nil
27+
}
28+
29+
func (e *execAction) Execute(ctx context.Context, cre map[string]any) error {
30+
args := make([]string, len(e.cfg.Args))
31+
for i, a := range e.cfg.Args {
32+
tmpl, err := template.New("arg").Funcs(funcMap()).Parse(a)
33+
if err != nil {
34+
return err
35+
}
36+
if err := executeTemplate(&args[i], tmpl, cre); err != nil {
37+
return err
38+
}
39+
}
40+
41+
raw, err := json.Marshal(cre)
42+
if err != nil {
43+
return err
44+
}
45+
46+
cmd := exec.CommandContext(ctx, e.cfg.Path, args...)
47+
cmd.Stdin = bytes.NewReader(raw)
48+
cmd.Stdout = os.Stdout
49+
cmd.Stderr = os.Stderr
50+
return cmd.Run()
51+
}

internal/pkg/runbook/jira.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package runbook
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
"text/template"
13+
"time"
14+
)
15+
16+
type jiraConfig struct {
17+
WebhookURL string `yaml:"webhook_url"`
18+
Secret string `yaml:"secret"` // optional
19+
SecretEnv string `yaml:"secret_env"` // optional
20+
SummaryTemplate string `yaml:"summary_template"`
21+
DescriptionTemplate string `yaml:"description_template"`
22+
ProjectKey string `yaml:"project_key"` // e.g. "PREQ"
23+
}
24+
25+
type jiraAction struct {
26+
cfg jiraConfig
27+
summaryTmpl *template.Template
28+
descTmpl *template.Template
29+
httpc *http.Client
30+
}
31+
32+
func newJiraAction(cfg jiraConfig) (Action, error) {
33+
if cfg.WebhookURL == "" {
34+
return nil, errors.New("jira.webhook_url is required")
35+
}
36+
if cfg.SummaryTemplate == "" {
37+
return nil, errors.New("jira.summary_template is required")
38+
}
39+
if cfg.ProjectKey == "" {
40+
return nil, errors.New("jira.project_key is required when using REST API mode")
41+
}
42+
st, err := template.New("jira-summary").Funcs(funcMap()).Parse(cfg.SummaryTemplate)
43+
if err != nil {
44+
return nil, err
45+
}
46+
dt, err := template.New("jira-desc").Funcs(funcMap()).Parse(cfg.DescriptionTemplate)
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
if cfg.Secret == "" && cfg.SecretEnv != "" {
52+
cfg.Secret = os.Getenv(cfg.SecretEnv)
53+
}
54+
// optional: hard‑fail if both were empty
55+
if cfg.Secret == "" {
56+
return nil, errors.New("jira secret missing; set either 'secret' or 'secret_env'")
57+
}
58+
59+
return &jiraAction{
60+
cfg: cfg,
61+
summaryTmpl: st,
62+
descTmpl: dt,
63+
httpc: &http.Client{
64+
Timeout: 5 * time.Second,
65+
},
66+
}, nil
67+
}
68+
69+
func (j *jiraAction) Execute(ctx context.Context, cre map[string]any) error {
70+
var summary, desc string
71+
if err := executeTemplate(&summary, j.summaryTmpl, cre); err != nil {
72+
return err
73+
}
74+
if err := executeTemplate(&desc, j.descTmpl, cre); err != nil {
75+
return err
76+
}
77+
payload := map[string]any{
78+
"project": map[string]any{"key": j.cfg.ProjectKey},
79+
"summary": summary,
80+
"description": adfParagraph(desc),
81+
"issuetype": map[string]any{"name": "Bug"},
82+
}
83+
body, _ := json.Marshal(payload)
84+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.cfg.WebhookURL,
85+
bytes.NewReader(body))
86+
if err != nil {
87+
return fmt.Errorf("jira post: %w", err)
88+
}
89+
req.Header.Set("Content-Type", "application/json")
90+
if j.cfg.Secret != "" {
91+
req.Header.Set("X-Automation-Webhook-Token", j.cfg.Secret)
92+
}
93+
resp, err := j.httpc.Do(req)
94+
if err != nil {
95+
return fmt.Errorf("jira post: %w", err)
96+
}
97+
defer resp.Body.Close()
98+
if resp.StatusCode >= 300 {
99+
respBody, _ := io.ReadAll(resp.Body)
100+
return fmt.Errorf("jira post failed: %s – %s", resp.Status, respBody)
101+
}
102+
return nil
103+
}
104+
105+
func adfParagraph(txt string) map[string]any {
106+
return map[string]any{
107+
"type": "doc",
108+
"version": 1,
109+
"content": []any{
110+
map[string]any{
111+
"type": "paragraph",
112+
"content": []any{
113+
map[string]any{
114+
"type": "text",
115+
"text": txt,
116+
},
117+
},
118+
},
119+
},
120+
}
121+
}

0 commit comments

Comments
 (0)