Skip to content

Commit 68b8238

Browse files
authored
Add slack-bot for nightly builds (#3214)
Added a slack bot to report the nightly build status on the slack channel, daily. Signed-off-by: Nahshon Unna-Tsameret <[email protected]>
1 parent 52ddda6 commit 68b8238

File tree

4 files changed

+335
-0
lines changed

4 files changed

+335
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Nightly-Build slack bot
2+
3+
on:
4+
schedule:
5+
- cron: '30 5 * * *'
6+
workflow_dispatch:
7+
8+
defaults:
9+
run:
10+
working-directory: ./automation/hco-nightly-reporter
11+
12+
jobs:
13+
build-and-run-slack-bot:
14+
name: Nightly-build slack bot
15+
if: github.repository == 'kubevirt/hyperconverged-cluster-operator'
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Set up Go
21+
uses: actions/setup-go@v5
22+
with:
23+
go-version-file: ./automation/hco-nightly-reporter/go.mod
24+
25+
- name: Build
26+
run: go build -v .
27+
28+
- name: run
29+
env:
30+
HCO_CHANNEL_ID: ${{ secrets.HCO_SLACK_CHANNEL_ID }}
31+
HCO_GROUP_ID: ${{ secrets.HCO_SLACK_GROUP_ID }}
32+
HCO_REPORTER_SLACK_TOKEN: ${{ secrets.HCO_REPORTER_SLACK_TOKEN }}
33+
run:
34+
./hco-nightly-reporter
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module github.com/hyperconverged-cluster-operator/automation/hco-nightly-reporter
2+
3+
go 1.23
4+
5+
require github.com/slack-go/slack v0.15.0
6+
7+
require (
8+
github.com/go-test/deep v1.1.1 // indirect
9+
github.com/google/go-cmp v0.6.0 // indirect
10+
github.com/gorilla/websocket v1.5.3 // indirect
11+
github.com/stretchr/testify v1.10.0 // indirect
12+
)
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
4+
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
5+
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
6+
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
7+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
8+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
9+
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
10+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
11+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
12+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
13+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
14+
github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0=
15+
github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
16+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
17+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
18+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
19+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
20+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
21+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+268
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"time"
11+
12+
"github.com/slack-go/slack"
13+
)
14+
15+
const (
16+
basicPrawURL = "https://storage.googleapis.com/kubevirt-prow/logs/periodic-hco-push-nightly-build-main"
17+
latestBuildURL = basicPrawURL + "/latest-build.txt"
18+
finishedURLTemplate = basicPrawURL + "/%s/finished.json"
19+
jobURLTemplate = basicPrawURL + "/%s/prowjob.json"
20+
21+
timeFormat = "2006-01-02, 15:04:05"
22+
)
23+
24+
type finished struct {
25+
Timestamp int64 `json:"timestamp"`
26+
Passed bool `json:"passed"`
27+
Result string `json:"result"`
28+
Revision string `json:"revision"`
29+
}
30+
31+
func (f finished) getBuildTime() time.Time {
32+
return time.Unix(f.Timestamp, 0).UTC()
33+
}
34+
35+
var (
36+
token string
37+
channelId string
38+
groupId string
39+
)
40+
41+
func init() {
42+
var ok bool
43+
token, ok = os.LookupEnv("HCO_REPORTER_SLACK_TOKEN")
44+
if !ok {
45+
fmt.Fprintln(os.Stderr, "HCO_REPORTER_SLACK_TOKEN environment variable not set")
46+
os.Exit(1)
47+
}
48+
49+
channelId, ok = os.LookupEnv("HCO_CHANNEL_ID")
50+
if !ok {
51+
fmt.Fprintln(os.Stderr, "HCO_CHANNEL_ID environment variable not set")
52+
os.Exit(1)
53+
}
54+
55+
groupId, ok = os.LookupEnv("HCO_GROUP_ID")
56+
if !ok {
57+
fmt.Fprintln(os.Stderr, "HCO_GROUP_ID environment variable not set")
58+
os.Exit(1)
59+
}
60+
}
61+
62+
func main() {
63+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
64+
defer cancel()
65+
66+
blocks, jobURL, err := generateMessage(ctx)
67+
if err != nil {
68+
fmt.Fprintln(os.Stderr, err)
69+
os.Exit(1)
70+
}
71+
72+
err = sendMessageToSlackChannel(blocks)
73+
74+
if err != nil {
75+
writeSendError(err, jobURL)
76+
os.Exit(1)
77+
}
78+
79+
fmt.Println("Successfully sent message to the channel")
80+
}
81+
82+
func writeSendError(err error, jobURL string) {
83+
fmt.Fprintln(os.Stderr, "failed to send the message to the channel; ", err.Error())
84+
if serr, ok := err.(slack.SlackErrorResponse); ok {
85+
for _, msg := range serr.ResponseMetadata.Messages {
86+
fmt.Fprintln(os.Stderr, msg)
87+
}
88+
}
89+
90+
if len(jobURL) > 0 {
91+
fmt.Fprintln(os.Stderr, "job URL: ", jobURL)
92+
}
93+
}
94+
95+
func generateMessage(ctx context.Context) ([]slack.Block, string, error) {
96+
client := http.DefaultClient
97+
client.Timeout = time.Second * 3
98+
99+
latestBuild, err := getLatestBuild(ctx, client)
100+
if err != nil {
101+
return nil, "", fmt.Errorf("failed to latest job ID; %s", err.Error())
102+
}
103+
104+
buildStatus, err := getBuildStatus(ctx, latestBuild)
105+
if err != nil {
106+
return nil, "", fmt.Errorf("failed to fetch the build status; %s", err.Error())
107+
}
108+
109+
buildTime := time.Unix(buildStatus.Timestamp, 0).UTC()
110+
if time.Since(buildTime).Hours() > 24 {
111+
return generateNoBuildMessage(buildTime), "", nil
112+
}
113+
114+
jobURL, err := getJob(ctx, latestBuild)
115+
if err != nil {
116+
return nil, "", fmt.Errorf("failed to fetch the job info; %s", err.Error())
117+
}
118+
119+
return generateStatusMessage(buildStatus, jobURL), jobURL, nil
120+
}
121+
122+
func sendMessageToSlackChannel(blocks []slack.Block) error {
123+
s := slack.New(token)
124+
_, _, err := s.PostMessage(channelId, slack.MsgOptionBlocks(blocks...))
125+
return err
126+
}
127+
128+
func generateMsgHeader() slack.Block {
129+
return slack.NewHeaderBlock(
130+
slack.NewTextBlockObject(
131+
"plain_text", "Nightly Build Status", false, false,
132+
),
133+
)
134+
}
135+
136+
func generateMentionBlock(blockId string) slack.Block {
137+
return slack.NewRichTextBlock(blockId, slack.NewRichTextSection(
138+
slack.NewRichTextSectionUserGroupElement(groupId),
139+
))
140+
}
141+
142+
func generateNoBuildMessage(buildTime time.Time) []slack.Block {
143+
return []slack.Block{
144+
generateMsgHeader(),
145+
slack.NewDividerBlock(),
146+
slack.NewRichTextBlock("1", slack.NewRichTextSection(
147+
slack.NewRichTextSectionEmojiElement("failed", 3, nil),
148+
)),
149+
slack.NewRichTextBlock("2", slack.NewRichTextSection(
150+
slack.NewRichTextSectionTextElement(
151+
"No new build today", nil,
152+
),
153+
)),
154+
slack.NewRichTextBlock("3", slack.NewRichTextSection(
155+
slack.NewRichTextSectionTextElement(
156+
fmt.Sprintf("Last build was at %v", buildTime.Format(timeFormat)),
157+
nil,
158+
),
159+
)),
160+
generateMentionBlock("4"),
161+
slack.NewDividerBlock(),
162+
}
163+
}
164+
165+
func generateStatusMessage(buildStatus *finished, jobURL string) []slack.Block {
166+
var status, emoji string
167+
if buildStatus.Passed {
168+
status = "passed"
169+
emoji = "solid-success"
170+
} else {
171+
status = "failed"
172+
emoji = "failed"
173+
}
174+
175+
ts := buildStatus.getBuildTime().Format(timeFormat)
176+
177+
blocks := []slack.Block{
178+
generateMsgHeader(),
179+
slack.NewDividerBlock(),
180+
slack.NewRichTextBlock("1", slack.NewRichTextSection(
181+
slack.NewRichTextSectionEmojiElement(emoji, 3, nil),
182+
)),
183+
slack.NewRichTextBlock("2", slack.NewRichTextSection(
184+
slack.NewRichTextSectionTextElement(
185+
"Status: ", nil,
186+
),
187+
slack.NewRichTextSectionLinkElement(jobURL, status, &slack.RichTextSectionTextStyle{Bold: true}),
188+
)),
189+
slack.NewRichTextBlock("3", slack.NewRichTextSection(
190+
slack.NewRichTextSectionTextElement(
191+
"Build time: "+ts+" UTC", nil,
192+
),
193+
)),
194+
}
195+
196+
if !buildStatus.Passed {
197+
blocks = append(blocks, slack.NewDividerBlock())
198+
blocks = append(blocks, generateMentionBlock("4"))
199+
}
200+
return blocks
201+
}
202+
203+
func getLatestBuild(ctx context.Context, client *http.Client) (string, error) {
204+
req, err := http.NewRequest(http.MethodGet, latestBuildURL, nil)
205+
if err != nil {
206+
return "", err
207+
}
208+
209+
resp, err := client.Do(req.WithContext(ctx))
210+
if err != nil {
211+
return "", err
212+
}
213+
214+
defer resp.Body.Close()
215+
216+
latestBuildBytes, err := io.ReadAll(resp.Body)
217+
if err != nil {
218+
return "", err
219+
}
220+
return string(latestBuildBytes), nil
221+
}
222+
223+
func getBuildStatus(ctx context.Context, latestBuild string) (*finished, error) {
224+
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(finishedURLTemplate, latestBuild), nil)
225+
if err != nil {
226+
return nil, err
227+
}
228+
229+
finishedResp, err := http.DefaultClient.Do(req.WithContext(ctx))
230+
if err != nil {
231+
return nil, err
232+
}
233+
234+
defer finishedResp.Body.Close()
235+
236+
f := &finished{}
237+
dec := json.NewDecoder(finishedResp.Body)
238+
if err = dec.Decode(&f); err != nil {
239+
return nil, err
240+
}
241+
return f, nil
242+
}
243+
244+
func getJob(ctx context.Context, latestBuild string) (string, error) {
245+
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(jobURLTemplate, latestBuild), nil)
246+
if err != nil {
247+
return "", err
248+
}
249+
250+
jobResp, err := http.DefaultClient.Do(req.WithContext(ctx))
251+
if err != nil {
252+
return "", err
253+
}
254+
255+
defer jobResp.Body.Close()
256+
257+
job := struct {
258+
Status struct {
259+
URL string `json:"url,omitempty"`
260+
} `json:"status"`
261+
}{}
262+
dec := json.NewDecoder(jobResp.Body)
263+
err = dec.Decode(&job)
264+
if err != nil {
265+
return "", err
266+
}
267+
return job.Status.URL, nil
268+
}

0 commit comments

Comments
 (0)