Skip to content

Commit 834938a

Browse files
feat: create issue button for a git url (#37)
1 parent e62c389 commit 834938a

8 files changed

Lines changed: 460 additions & 50 deletions

File tree

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ require (
77
github.com/PagerDuty/go-pdagent v0.5.1
88
github.com/gravwell/gravwell/v3 v3.8.34
99
github.com/jasonlvhit/gocron v0.0.1
10-
github.com/kevincobain2000/go-msteams v1.1.1
1110
github.com/mattn/go-isatty v0.0.20
1211
github.com/natefinch/lumberjack v2.0.0+incompatible
1312
github.com/patrickmn/go-cache v2.1.0+incompatible

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,6 @@ github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22
7373
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
7474
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
7575
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
76-
github.com/kevincobain2000/go-msteams v1.1.1 h1:vZ8AYvVmiCdC+VZwsw7RFhb89RG/GasX9kvbdKheFN4=
77-
github.com/kevincobain2000/go-msteams v1.1.1/go.mod h1:+HowoQQHg9HLfx3CYQGImGGYw20+kN9rFmUXgxrqBzo=
7876
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
7977
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
8078
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ var httpClient *http.Client
3535

3636
// setHTTPClient initializes the singleton HTTP client with timeout and proxy configuration
3737
func setHTTPClient() error {
38-
timeout := time.Duration(5 * time.Second)
38+
timeout := time.Duration(3 * time.Second)
3939

4040
if f.Proxy == "" {
4141
httpClient = &http.Client{

pkg/flags.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Flags struct {
2020
LogLevel int
2121
MemLimit int
2222
MSTeamsHook string
23+
GitURL string
2324
PagerDutyKey string
2425
PagerDutyDedupKey string
2526
MaxBufferMB int
@@ -53,6 +54,7 @@ go-watch-logs --file-path=./ssl_access.*log --test
5354

5455
flag.StringVar(&f.Proxy, "proxy", "", "http proxy for webhooks")
5556
flag.StringVar(&f.MSTeamsHook, "ms-teams-hook", "", "ms teams webhook")
57+
flag.StringVar(&f.GitURL, "git-url", "", "git repo URL (e.g. github.com/org/repo) for MS Teams issue button")
5658
flag.StringVar(&f.PagerDutyKey, "pagerduty-key", "", "pagerduty routing/integration key")
5759
flag.StringVar(&f.PagerDutyDedupKey, "pagerduty-dedupkey", "", "pagerduty uniq key, for grpuping events")
5860
flag.StringVar(&f.Severity, "severity", "error", "severity level for alerts (e.g. info, warning, error, critical)")

pkg/log.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,17 @@ const (
1919

2020
// GlobalHandler is a custom handler that catches all logs
2121
type GlobalHandler struct {
22-
next slog.Handler
23-
msTeamsHook string
24-
pagerDutyKey string
25-
proxy string
26-
httpClient *http.Client
22+
next slog.Handler
23+
msTeamsHook string
24+
pagerDutyKey string
25+
httpClient *http.Client
2726
}
2827

2928
func (h *GlobalHandler) Handle(ctx context.Context, r slog.Record) error {
3029
if r.Level.String() == SlogErrorLabel {
3130
err := fmt.Errorf("global log capture - Level: %s, Message: %s", r.Level.String(), r.Message)
3231
if h.msTeamsHook != "" {
33-
NotifyOwnErrorToTeams(err, r, h.msTeamsHook, h.proxy)
32+
NotifyOwnErrorToTeams(err, r, h.msTeamsHook, h.httpClient)
3433
}
3534
if h.pagerDutyKey != "" {
3635
NotifyOwnErrorToPagerDuty(err, r, h.pagerDutyKey, h.httpClient)
@@ -80,7 +79,6 @@ func SetupLoggingStdout(f Flags, httpClient *http.Client) error {
8079
next: handler,
8180
msTeamsHook: f.MSTeamsHook,
8281
pagerDutyKey: f.PagerDutyKey,
83-
proxy: f.Proxy,
8482
httpClient: httpClient,
8583
}
8684
slog.SetDefault(slog.New(globalHandler))

pkg/msteams.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package pkg
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"net/url"
11+
"os"
12+
"strings"
13+
)
14+
15+
type Details struct {
16+
Label string
17+
Message string
18+
}
19+
20+
type teamsCard struct {
21+
Type string `json:"type"`
22+
Attachments []teamsAttachment `json:"attachments"`
23+
}
24+
25+
type teamsAttachment struct {
26+
ContentType string `json:"contentType"`
27+
ContentURL *string `json:"contentUrl"`
28+
Content teamsCardContent `json:"content"`
29+
}
30+
31+
type teamsCardContent struct {
32+
Schema string `json:"$schema"`
33+
Type string `json:"type"`
34+
Version string `json:"version"`
35+
AccentColor string `json:"accentColor"`
36+
Body []interface{} `json:"body"`
37+
Actions []teamsAction `json:"actions,omitempty"`
38+
MSTeams teamsMSTeams `json:"msteams"`
39+
}
40+
41+
type teamsTextBlock struct {
42+
Type string `json:"type"`
43+
Text string `json:"text"`
44+
ID string `json:"id,omitempty"`
45+
Size string `json:"size,omitempty"`
46+
Weight string `json:"weight,omitempty"`
47+
Color string `json:"color,omitempty"`
48+
}
49+
50+
type teamsFact struct {
51+
Title string `json:"title"`
52+
Value string `json:"value"`
53+
}
54+
55+
type teamsFactSet struct {
56+
Type string `json:"type"`
57+
Facts []teamsFact `json:"facts"`
58+
ID string `json:"id"`
59+
}
60+
61+
type teamsAction struct {
62+
Type string `json:"type"`
63+
Title string `json:"title"`
64+
URL string `json:"url"`
65+
}
66+
67+
type teamsMSTeams struct {
68+
Width string `json:"width"`
69+
}
70+
71+
func normalizeGitURL(rawURL string) string {
72+
u := strings.TrimSpace(rawURL)
73+
u = strings.TrimRight(u, "/")
74+
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
75+
return u
76+
}
77+
return "https://" + u
78+
}
79+
80+
func actionButton(title string, details []Details, gitURL string) []teamsAction {
81+
if gitURL == "" {
82+
return nil
83+
}
84+
var filePath, match, ignore, lines string
85+
for _, d := range details {
86+
switch d.Label {
87+
case "File":
88+
filePath = d.Message
89+
case "Match":
90+
match = d.Message
91+
case "Ignore":
92+
ignore = d.Message
93+
case "Lines":
94+
lines = d.Message
95+
}
96+
}
97+
issueBody := fmt.Sprintf("**File:** %s\n**Match:** %s\n**Ignore:** %s\n\n**Lines:**\n```\n%s\n```", filePath, match, ignore, lines)
98+
q := url.Values{}
99+
q.Set("title", title)
100+
q.Set("body", issueBody)
101+
q.Set("labels", "go-watch-logs")
102+
normalizedURL := normalizeGitURL(gitURL)
103+
issueURL := normalizedURL + "/issues/new?" + q.Encode()
104+
buttonTitle := "Create issue"
105+
if parsed, err := url.Parse(normalizedURL); err == nil {
106+
if orgRepo := strings.TrimLeft(parsed.Path, "/"); orgRepo != "" {
107+
buttonTitle = "Create Issue on " + orgRepo
108+
}
109+
}
110+
return []teamsAction{{
111+
Type: "Action.OpenUrl",
112+
Title: buttonTitle,
113+
URL: issueURL,
114+
}}
115+
}
116+
117+
func sendToTeams(title string, details []Details, gitURL, hookURL string, httpClient *http.Client) error {
118+
facts := make([]teamsFact, len(details))
119+
for i, d := range details {
120+
facts[i] = teamsFact{Title: d.Label, Value: d.Message}
121+
}
122+
123+
actions := actionButton(title, details, gitURL)
124+
125+
card := teamsCard{
126+
Type: "message",
127+
Attachments: []teamsAttachment{
128+
{
129+
ContentType: "application/vnd.microsoft.card.adaptive",
130+
ContentURL: nil,
131+
Content: teamsCardContent{
132+
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
133+
Type: "AdaptiveCard",
134+
Version: "1.4",
135+
AccentColor: "bf0000",
136+
Body: []interface{}{
137+
teamsTextBlock{
138+
Type: "TextBlock",
139+
Text: title,
140+
ID: "title",
141+
Size: "large",
142+
Weight: "bolder",
143+
Color: "accent",
144+
},
145+
teamsFactSet{
146+
Type: "FactSet",
147+
Facts: facts,
148+
ID: "acFactSet",
149+
},
150+
},
151+
Actions: actions,
152+
MSTeams: teamsMSTeams{Width: "Full"},
153+
},
154+
},
155+
},
156+
}
157+
158+
requestBody, err := json.Marshal(card)
159+
if err != nil {
160+
return err
161+
}
162+
163+
req, err := http.NewRequest("POST", hookURL, bytes.NewBuffer(requestBody))
164+
if err != nil {
165+
return err
166+
}
167+
req.Header.Set("Content-type", "application/json")
168+
169+
resp, err := httpClient.Do(req) //nolint:gosec // hookURL is user-configured webhook, not attacker-controlled
170+
if err != nil {
171+
return err
172+
}
173+
defer resp.Body.Close()
174+
_, err = io.ReadAll(resp.Body)
175+
return err
176+
}
177+
178+
func NotifyOwnErrorToTeams(e error, r slog.Record, msTeamsHook string, httpClient *http.Client) {
179+
hostname, _ := os.Hostname()
180+
slog.Info("Sending own error to MS Teams")
181+
182+
details := []Details{
183+
{
184+
Label: "Hostname",
185+
Message: hostname,
186+
},
187+
{
188+
Label: "Error",
189+
Message: e.Error(),
190+
},
191+
}
192+
r.Attrs(func(attr slog.Attr) bool {
193+
details = append(details, Details{
194+
Label: attr.Key,
195+
Message: fmt.Sprintf("%v", attr.Value),
196+
})
197+
return true
198+
})
199+
200+
err := sendToTeams(hostname, details, "", msTeamsHook, httpClient)
201+
if err != nil {
202+
// keep it warn to prevent infinite loop from the global handler of slog
203+
slog.Warn("Error sending to Teams", "error", err.Error())
204+
return
205+
}
206+
slog.Info("Successfully sent own error to MS Teams")
207+
}

0 commit comments

Comments
 (0)