Skip to content

Commit

Permalink
Add slack-bot for nightly builds (#3214)
Browse files Browse the repository at this point in the history
Added a slack bot to report the nightly build status on the slack
channel, daily.

Signed-off-by: Nahshon Unna-Tsameret <[email protected]>
  • Loading branch information
nunnatsa authored Dec 19, 2024
1 parent 52ddda6 commit 68b8238
Show file tree
Hide file tree
Showing 4 changed files with 335 additions and 0 deletions.
34 changes: 34 additions & 0 deletions .github/workflows/nightly-build-slack-bot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Nightly-Build slack bot

on:
schedule:
- cron: '30 5 * * *'
workflow_dispatch:

defaults:
run:
working-directory: ./automation/hco-nightly-reporter

jobs:
build-and-run-slack-bot:
name: Nightly-build slack bot
if: github.repository == 'kubevirt/hyperconverged-cluster-operator'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ./automation/hco-nightly-reporter/go.mod

- name: Build
run: go build -v .

- name: run
env:
HCO_CHANNEL_ID: ${{ secrets.HCO_SLACK_CHANNEL_ID }}
HCO_GROUP_ID: ${{ secrets.HCO_SLACK_GROUP_ID }}
HCO_REPORTER_SLACK_TOKEN: ${{ secrets.HCO_REPORTER_SLACK_TOKEN }}
run:
./hco-nightly-reporter
12 changes: 12 additions & 0 deletions automation/hco-nightly-reporter/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module github.com/hyperconverged-cluster-operator/automation/hco-nightly-reporter

go 1.23

require github.com/slack-go/slack v0.15.0

require (
github.com/go-test/deep v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/stretchr/testify v1.10.0 // indirect
)
21 changes: 21 additions & 0 deletions automation/hco-nightly-reporter/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0=
github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
268 changes: 268 additions & 0 deletions automation/hco-nightly-reporter/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"

"github.com/slack-go/slack"
)

const (
basicPrawURL = "https://storage.googleapis.com/kubevirt-prow/logs/periodic-hco-push-nightly-build-main"
latestBuildURL = basicPrawURL + "/latest-build.txt"
finishedURLTemplate = basicPrawURL + "/%s/finished.json"
jobURLTemplate = basicPrawURL + "/%s/prowjob.json"

timeFormat = "2006-01-02, 15:04:05"
)

type finished struct {
Timestamp int64 `json:"timestamp"`
Passed bool `json:"passed"`
Result string `json:"result"`
Revision string `json:"revision"`
}

func (f finished) getBuildTime() time.Time {
return time.Unix(f.Timestamp, 0).UTC()
}

var (
token string
channelId string
groupId string
)

func init() {
var ok bool
token, ok = os.LookupEnv("HCO_REPORTER_SLACK_TOKEN")
if !ok {
fmt.Fprintln(os.Stderr, "HCO_REPORTER_SLACK_TOKEN environment variable not set")
os.Exit(1)
}

channelId, ok = os.LookupEnv("HCO_CHANNEL_ID")
if !ok {
fmt.Fprintln(os.Stderr, "HCO_CHANNEL_ID environment variable not set")
os.Exit(1)
}

groupId, ok = os.LookupEnv("HCO_GROUP_ID")
if !ok {
fmt.Fprintln(os.Stderr, "HCO_GROUP_ID environment variable not set")
os.Exit(1)
}
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()

blocks, jobURL, err := generateMessage(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

err = sendMessageToSlackChannel(blocks)

if err != nil {
writeSendError(err, jobURL)
os.Exit(1)
}

fmt.Println("Successfully sent message to the channel")
}

func writeSendError(err error, jobURL string) {
fmt.Fprintln(os.Stderr, "failed to send the message to the channel; ", err.Error())
if serr, ok := err.(slack.SlackErrorResponse); ok {
for _, msg := range serr.ResponseMetadata.Messages {
fmt.Fprintln(os.Stderr, msg)
}
}

if len(jobURL) > 0 {
fmt.Fprintln(os.Stderr, "job URL: ", jobURL)
}
}

func generateMessage(ctx context.Context) ([]slack.Block, string, error) {
client := http.DefaultClient
client.Timeout = time.Second * 3

latestBuild, err := getLatestBuild(ctx, client)
if err != nil {
return nil, "", fmt.Errorf("failed to latest job ID; %s", err.Error())
}

buildStatus, err := getBuildStatus(ctx, latestBuild)
if err != nil {
return nil, "", fmt.Errorf("failed to fetch the build status; %s", err.Error())
}

buildTime := time.Unix(buildStatus.Timestamp, 0).UTC()
if time.Since(buildTime).Hours() > 24 {
return generateNoBuildMessage(buildTime), "", nil
}

jobURL, err := getJob(ctx, latestBuild)
if err != nil {
return nil, "", fmt.Errorf("failed to fetch the job info; %s", err.Error())
}

return generateStatusMessage(buildStatus, jobURL), jobURL, nil
}

func sendMessageToSlackChannel(blocks []slack.Block) error {
s := slack.New(token)
_, _, err := s.PostMessage(channelId, slack.MsgOptionBlocks(blocks...))
return err
}

func generateMsgHeader() slack.Block {
return slack.NewHeaderBlock(
slack.NewTextBlockObject(
"plain_text", "Nightly Build Status", false, false,
),
)
}

func generateMentionBlock(blockId string) slack.Block {
return slack.NewRichTextBlock(blockId, slack.NewRichTextSection(
slack.NewRichTextSectionUserGroupElement(groupId),
))
}

func generateNoBuildMessage(buildTime time.Time) []slack.Block {
return []slack.Block{
generateMsgHeader(),
slack.NewDividerBlock(),
slack.NewRichTextBlock("1", slack.NewRichTextSection(
slack.NewRichTextSectionEmojiElement("failed", 3, nil),
)),
slack.NewRichTextBlock("2", slack.NewRichTextSection(
slack.NewRichTextSectionTextElement(
"No new build today", nil,
),
)),
slack.NewRichTextBlock("3", slack.NewRichTextSection(
slack.NewRichTextSectionTextElement(
fmt.Sprintf("Last build was at %v", buildTime.Format(timeFormat)),
nil,
),
)),
generateMentionBlock("4"),
slack.NewDividerBlock(),
}
}

func generateStatusMessage(buildStatus *finished, jobURL string) []slack.Block {
var status, emoji string
if buildStatus.Passed {
status = "passed"
emoji = "solid-success"
} else {
status = "failed"
emoji = "failed"
}

ts := buildStatus.getBuildTime().Format(timeFormat)

blocks := []slack.Block{
generateMsgHeader(),
slack.NewDividerBlock(),
slack.NewRichTextBlock("1", slack.NewRichTextSection(
slack.NewRichTextSectionEmojiElement(emoji, 3, nil),
)),
slack.NewRichTextBlock("2", slack.NewRichTextSection(
slack.NewRichTextSectionTextElement(
"Status: ", nil,
),
slack.NewRichTextSectionLinkElement(jobURL, status, &slack.RichTextSectionTextStyle{Bold: true}),
)),
slack.NewRichTextBlock("3", slack.NewRichTextSection(
slack.NewRichTextSectionTextElement(
"Build time: "+ts+" UTC", nil,
),
)),
}

if !buildStatus.Passed {
blocks = append(blocks, slack.NewDividerBlock())
blocks = append(blocks, generateMentionBlock("4"))
}
return blocks
}

func getLatestBuild(ctx context.Context, client *http.Client) (string, error) {
req, err := http.NewRequest(http.MethodGet, latestBuildURL, nil)
if err != nil {
return "", err
}

resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return "", err
}

defer resp.Body.Close()

latestBuildBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(latestBuildBytes), nil
}

func getBuildStatus(ctx context.Context, latestBuild string) (*finished, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(finishedURLTemplate, latestBuild), nil)
if err != nil {
return nil, err
}

finishedResp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}

defer finishedResp.Body.Close()

f := &finished{}
dec := json.NewDecoder(finishedResp.Body)
if err = dec.Decode(&f); err != nil {
return nil, err
}
return f, nil
}

func getJob(ctx context.Context, latestBuild string) (string, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(jobURLTemplate, latestBuild), nil)
if err != nil {
return "", err
}

jobResp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return "", err
}

defer jobResp.Body.Close()

job := struct {
Status struct {
URL string `json:"url,omitempty"`
} `json:"status"`
}{}
dec := json.NewDecoder(jobResp.Body)
err = dec.Decode(&job)
if err != nil {
return "", err
}
return job.Status.URL, nil
}

0 comments on commit 68b8238

Please sign in to comment.