From 19b8b3382e1496e26fa4b794176b058b0656c84d Mon Sep 17 00:00:00 2001 From: Matthias Loibl Date: Tue, 8 Aug 2017 12:08:16 +0200 Subject: [PATCH] Add all Go source files --- main.go | 80 +++++++++++++++++++++++++++ release.go | 15 ++++++ releasechecker.go | 135 ++++++++++++++++++++++++++++++++++++++++++++++ repository.go | 13 +++++ slack.go | 62 +++++++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 main.go create mode 100644 release.go create mode 100644 releasechecker.go create mode 100644 repository.go create mode 100644 slack.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..6f9bcc3 --- /dev/null +++ b/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "os" + "strings" + "time" + + "github.com/alexflint/go-arg" + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" + "github.com/joho/godotenv" + "github.com/shurcooL/githubql" + "golang.org/x/oauth2" +) + +// Config of env and args +type Config struct { + GithubToken string `arg:"env:GITHUB_TOKEN"` + Interval time.Duration `arg:"env:INTERVAL"` + LogLevel string `arg:"env:LOG_LEVEL"` + Repositories []string `arg:"-r,separate"` + SlackHook string `arg:"env:SLACK_HOOK"` +} + +// Token returns an oauth2 token or an error. +func (c Config) Token() *oauth2.Token { + return &oauth2.Token{AccessToken: c.GithubToken} +} + +func main() { + _ = godotenv.Load() + + c := Config{ + Interval: time.Hour, + LogLevel: "info", + } + arg.MustParse(&c) + + logger := log.NewJSONLogger(log.NewSyncWriter(os.Stdout)) + logger = log.With(logger, + "ts", log.DefaultTimestampUTC, + "caller", log.Caller(5), + ) + + level.SetKey("severity") + switch strings.ToLower(c.LogLevel) { + case "debug": + logger = level.NewFilter(logger, level.AllowDebug()) + case "warn": + logger = level.NewFilter(logger, level.AllowWarn()) + case "error": + logger = level.NewFilter(logger, level.AllowError()) + default: + logger = level.NewFilter(logger, level.AllowInfo()) + } + + tokenSource := oauth2.StaticTokenSource(c.Token()) + client := oauth2.NewClient(context.Background(), tokenSource) + checker := &Checker{ + logger: logger, + client: githubql.NewClient(client), + } + + releases := make(chan Repository) + go checker.Run(c.Interval, c.Repositories, releases) + + slack := SlackSender{Hook: c.SlackHook} + + level.Info(logger).Log("msg", "waiting for new releases") + for repository := range releases { + if err := slack.Send(repository); err != nil { + level.Warn(logger).Log( + "msg", "failed to send release to messenger", + "err", err, + ) + continue + } + } +} diff --git a/release.go b/release.go new file mode 100644 index 0000000..7d58ae5 --- /dev/null +++ b/release.go @@ -0,0 +1,15 @@ +package main + +import ( + "net/url" + "time" +) + +// Release of a repository tagged via GitHub. +type Release struct { + ID string + Name string + Description string + URL url.URL + PublishedAt time.Time +} diff --git a/releasechecker.go b/releasechecker.go new file mode 100644 index 0000000..f62f746 --- /dev/null +++ b/releasechecker.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" + "github.com/shurcooL/githubql" +) + +// Checker has a githubql client to run queries and also knows about +// the current repositories releases to compare against. +type Checker struct { + logger log.Logger + client *githubql.Client + releases map[string]Repository +} + +// Run the queries and comparisons for the given repositories in a given interval. +func (c *Checker) Run(interval time.Duration, repositories []string, releases chan<- Repository) { + if c.releases == nil { + c.releases = make(map[string]Repository) + } + + for { + for _, repoName := range repositories { + s := strings.Split(repoName, "/") + owner, name := s[0], s[1] + + nextRepo, err := c.query(owner, name) + if err != nil { + level.Warn(c.logger).Log( + "msg", "failed to query the repository's releases", + "owner", owner, + "name", name, + "err", err, + ) + continue + } + + // For debugging uncomment this next line + //releases <- nextRepo + + currRepo, ok := c.releases[repoName] + + // We've queried the repository for the first time. + // Saving the current state to compare with the next iteration. + if !ok { + c.releases[repoName] = nextRepo + continue + } + + if nextRepo.Release.PublishedAt.After(currRepo.Release.PublishedAt) { + releases <- nextRepo + c.releases[repoName] = nextRepo + } else { + level.Debug(c.logger).Log( + "msg", "no new release for repository", + "owner", owner, + "name", name, + ) + } + } + time.Sleep(interval) + } +} + +// This should be improved in the future to make batch requests for all watched repositories at once +// TODO: https://github.com/shurcooL/githubql/issues/17 + +func (c *Checker) query(owner, name string) (Repository, error) { + var query struct { + Repository struct { + ID githubql.ID + Name githubql.String + Description githubql.String + URL githubql.URI + + Releases struct { + Edges []struct { + Node struct { + ID githubql.ID + Name githubql.String + Description githubql.String + URL githubql.URI + PublishedAt githubql.DateTime + } + } + } `graphql:"releases(last: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubql.String(owner), + "name": githubql.String(name), + } + + if err := c.client.Query(context.Background(), &query, variables); err != nil { + return Repository{}, err + } + + repositoryID, ok := query.Repository.ID.(string) + if !ok { + return Repository{}, fmt.Errorf("can't convert repository id to string: %v", query.Repository.ID) + } + + if len(query.Repository.Releases.Edges) == 0 { + return Repository{}, fmt.Errorf("can't find any releases for %s/%s", owner, name) + } + latestRelease := query.Repository.Releases.Edges[0].Node + + releaseID, ok := latestRelease.ID.(string) + if !ok { + return Repository{}, fmt.Errorf("can't convert release id to string: %v", query.Repository.ID) + } + + return Repository{ + ID: repositoryID, + Name: string(query.Repository.Name), + Owner: owner, + Description: string(query.Repository.Description), + URL: *query.Repository.URL.URL, + + Release: Release{ + ID: releaseID, + Name: string(latestRelease.Name), + Description: string(latestRelease.Description), + URL: *latestRelease.URL.URL, + PublishedAt: latestRelease.PublishedAt.Time, + }, + }, nil +} diff --git a/repository.go b/repository.go new file mode 100644 index 0000000..412b961 --- /dev/null +++ b/repository.go @@ -0,0 +1,13 @@ +package main + +import "net/url" + +// Repository on GitHub. +type Repository struct { + ID string + Name string + Owner string + Description string + URL url.URL + Release Release +} diff --git a/slack.go b/slack.go new file mode 100644 index 0000000..d83cc46 --- /dev/null +++ b/slack.go @@ -0,0 +1,62 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +type SlackSender struct { + Hook string +} + +type slackPayload struct { + Username string `json:"username"` + IconEmoji string `json:"icon_emoji"` + Text string `json:"text"` +} + +func (s *SlackSender) Send(repository Repository) error { + payload := slackPayload{ + Username: "GitHub Releases", + IconEmoji: ":github:", + Text: fmt.Sprintf( + "<%s|%s/%s>: <%s|%s> released", + repository.URL.String(), + repository.Owner, + repository.Name, + repository.Release.URL.String(), + repository.Release.Name, + ), + } + + payloadData, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, s.Hook, bytes.NewReader(payloadData)) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return fmt.Errorf("request didn't respond with 200 OK: %s, %s", resp.Status, body) + } + + return nil +}