diff --git a/charts/argobot/Chart.yaml b/charts/argobot/Chart.yaml index 6b76e49..18b46b6 100644 --- a/charts/argobot/Chart.yaml +++ b/charts/argobot/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: argobot description: Helm chart for the corymurphy/argobot app type: application -version: 0.15.0 -appVersion: 0.15.0 +version: 0.16.0 +appVersion: 0.16.0 diff --git a/charts/argobot/templates/deployment.yaml b/charts/argobot/templates/deployment.yaml index 6e6fab9..f54a5d9 100644 --- a/charts/argobot/templates/deployment.yaml +++ b/charts/argobot/templates/deployment.yaml @@ -63,6 +63,8 @@ spec: readOnly: true {{- end }} env: + - name: ARGOBOT_LOG_LEVEL + value: {{ .Values.webServer.logLevel | default "info" }} - name: ARGOBOT_GH_WEBHOOK_SECRET valueFrom: secretKeyRef: diff --git a/charts/argobot/values.yaml b/charts/argobot/values.yaml index 75886ee..71d0983 100644 --- a/charts/argobot/values.yaml +++ b/charts/argobot/values.yaml @@ -48,6 +48,7 @@ config: | argoCdUrl: http://argocd-server.argocd.svc.cluster.local:80 webServer: + logLevel: info resources: requests: cpu: 250m diff --git a/pkg/argocd/planner.go b/pkg/argocd/planner.go new file mode 100644 index 0000000..15ae143 --- /dev/null +++ b/pkg/argocd/planner.go @@ -0,0 +1,48 @@ +package argocd + +import ( + "context" + + "github.com/corymurphy/argobot/pkg/logging" +) + +type Planner struct { + ArgoClient *ApplicationsClient + Log logging.SimpleLogging +} + +func NewPlanner(client *ApplicationsClient, log logging.SimpleLogging) *Planner { + return &Planner{ + ArgoClient: client, + Log: log, + } +} + +func (p *Planner) Plan(ctx context.Context, name string, revision string) (string, bool, error) { + var plan string + var diff bool = false + + resources, err := p.ArgoClient.ManagedResources(name) + + if err != nil { + return plan, diff, err + } + + live, err := p.ArgoClient.Get(name) + + if err != nil { + return plan, diff, err + } + + target, err := p.ArgoClient.GetManifest(name, revision) + if err != nil { + return plan, diff, err + } + + settings, err := p.ArgoClient.GetSettings() + if err != nil { + return plan, diff, err + } + + return Plan(ctx, &settings, live, resources, target, revision, p.Log) +} diff --git a/pkg/assert/logger.go b/pkg/assert/logger.go index b2adef6..1a7d5ca 100644 --- a/pkg/assert/logger.go +++ b/pkg/assert/logger.go @@ -3,6 +3,8 @@ package assert import ( "fmt" "testing" + + "github.com/pkg/errors" ) type TestLogger struct { @@ -32,6 +34,10 @@ func (t *TestLogger) Warn(format string, a ...interface{}) { t.log(format, a...) } -func (t *TestLogger) Err(format string, a ...interface{}) { - t.log(format, a...) +func (t *TestLogger) Err(err error, message string) { + t.t.Logf("%v", errors.Wrap(err, message)) } + +// func (t *TestLogger) Err(format string, a ...interface{}) { +// t.log(format, a...) +// } diff --git a/pkg/cli/start.go b/pkg/cli/start.go index f6c0f40..6f57ef7 100644 --- a/pkg/cli/start.go +++ b/pkg/cli/start.go @@ -12,6 +12,8 @@ import ( const ( WebhookSecretEnvVar = "ARGOBOT_GH_WEBHOOK_SECRET" + LogLevelEnvVar = "ARGOBOT_LOG_LEVEL" + DefaultLogLevel = logging.Info ) var ( @@ -38,6 +40,14 @@ var run = &cobra.Command{ config.Github.App.PrivateKey = string(content) config.Github.App.WebhookSecret = os.Getenv(WebhookSecretEnvVar) + logLevel := DefaultLogLevel + if serializedLevel, exists := os.LookupEnv(LogLevelEnvVar); exists { + logLevel, err = logging.GetLogLevel(serializedLevel) + if err != nil { + panic(err) + } + } + argoClient := &argocd.ApplicationsClient{ BaseUrl: config.ArgoCdUrl, } @@ -48,7 +58,7 @@ var run = &cobra.Command{ server.NewServer( config, - logging.NewLogger(logging.Info), + logging.NewLogger(logLevel), argoClient, ).Start() }, diff --git a/pkg/events/application_resolver.go b/pkg/events/application_resolver.go index 1d8835b..f891f35 100644 --- a/pkg/events/application_resolver.go +++ b/pkg/events/application_resolver.go @@ -26,7 +26,7 @@ func NewApplicationResolver(githubClient *gogithub.Client, argocdClient *argocd. } } -func (a *ApplicationResolver) FindApplicationNames(ctx context.Context, command *CommentCommand, event github.Event) ([]string, error) { +func (a *ApplicationResolver) FindApplicationNames(ctx context.Context, event github.Event) ([]string, error) { var changedApps []string modified, err := a.GetModifiedFiles(ctx, event) @@ -57,7 +57,7 @@ func (a *ApplicationResolver) FindApplicationNames(ctx context.Context, command // copied from atlantis func (a *ApplicationResolver) GetModifiedFiles(ctx context.Context, event github.Event) ([]string, error) { - a.Log.Debug("Getting modified files for GitHub pull request %d") + a.Log.Debug("Getting modified files for GitHub pull request") var files []string nextPage := 0 diff --git a/pkg/events/apply_runner.go b/pkg/events/apply_runner.go index 6bdfccf..a7893d3 100644 --- a/pkg/events/apply_runner.go +++ b/pkg/events/apply_runner.go @@ -28,7 +28,7 @@ func NewApplyRunner(vcsClient *github.Client, config *env.Config, log logging.Si } // TODO: validate that the PR is in an approved/mergeable state -func (a *ApplyRunner) Run(ctx context.Context, cmd *CommentCommand, event vsc.Event) (CommentResponse, error) { +func (a *ApplyRunner) Run(ctx context.Context, app string, event vsc.Event) (CommentResponse, error) { var resp CommentResponse status, err := vsc.NewPullRequestStatusFetcher(ctx, a.Log, a.vcsClient).Fetch(event) @@ -43,7 +43,7 @@ func (a *ApplyRunner) Run(ctx context.Context, cmd *CommentCommand, event vsc.Ev return NewCommentResponse("pull request must be approved and in a mergeable state", event), nil } - apply, err := a.ApplyClient.Apply(cmd.Application, event.Revision) + apply, err := a.ApplyClient.Apply(app, event.Revision) if err != nil { return resp, fmt.Errorf("argoclient failed while applying %w", err) } diff --git a/pkg/events/comment_command.go b/pkg/events/comment_command.go index 58090fe..112f11f 100644 --- a/pkg/events/comment_command.go +++ b/pkg/events/comment_command.go @@ -2,9 +2,18 @@ package events import "github.com/corymurphy/argobot/pkg/command" +func NewAutoRunCommand(apps []string, name command.Name) *CommentCommand { + return &CommentCommand{ + Flags: []string{}, + Name: name, + Applications: apps, + ExplicitApplication: false, + } +} + type CommentCommand struct { - Flags []string - Name command.Name - Application string - Applications []string + Flags []string + Name command.Name + Applications []string + ExplicitApplication bool } diff --git a/pkg/events/comment_handler.go b/pkg/events/comment_handler.go deleted file mode 100644 index 3831117..0000000 --- a/pkg/events/comment_handler.go +++ /dev/null @@ -1,194 +0,0 @@ -package events - -import ( - "context" - "fmt" - - "github.com/argoproj/argo-cd/v2/pkg/apiclient/application" - "github.com/corymurphy/argobot/pkg/argocd" - "github.com/corymurphy/argobot/pkg/command" - "github.com/corymurphy/argobot/pkg/env" - vsc "github.com/corymurphy/argobot/pkg/github" - "github.com/corymurphy/argobot/pkg/logging" - "github.com/corymurphy/argobot/pkg/utils" - "github.com/google/go-github/v53/github" - "github.com/palantir/go-githubapp/githubapp" - "github.com/pkg/errors" -) - -const maxCommentLength = 32768 - -type PRCommentHandler struct { - githubapp.ClientCreator - Config *env.Config - Log logging.SimpleLogging - ArgoClient argocd.ApplicationsClient -} - -func (h *PRCommentHandler) Handles() []string { - return []string{"issue_comment", "pull_request"} -} - -func (h *PRCommentHandler) Handle(ctx context.Context, eventType string, deliveryID string, payload []byte) error { - // var event github.IssueCommentEvent - // event.GetInstallation() - // var pr github.PullRequestEvent - event, err := vsc.NewEvent(eventType, payload) - if err != nil { - h.Log.Err(err, "unable to parse event metadata") - return nil - } - - comment := NewCommentParser(h.Log).Parse(event) - if (comment.Ignore || comment.ImmediateResponse) && !comment.HasResponseComment { - return nil - } - - installationID := githubapp.GetInstallationIDFromEvent(&event) - - client, err := h.NewInstallationClient(installationID) - if err != nil { - return err - } - - pr, _, err := client.PullRequests.Get(ctx, event.Repository.Owner, event.Repository.Name, event.PullRequest.Number) - if err != nil { - return err - } - - // TODO: this should happen in NewEvent - event.Revision = *pr.GetHead().SHA - - if (comment.Ignore || comment.ImmediateResponse) && comment.HasResponseComment { - prComment := github.IssueComment{ - Body: &comment.CommentResponse, - } - - if _, _, err := client.Issues.CreateComment(ctx, event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, &prComment); err != nil { - return err - } - } - // TODO: add reaction like this - // err := e.VCSClient.ReactToComment(logger, baseRepo, pullNum, commentID, e.EmojiReaction) - - // TODO: apply and plan async and respond to hook immediately - - // TODO: move this to startup and cache? - - if comment.Command.Application == "" { - appResolver := NewApplicationResolver(client, &h.ArgoClient, h.Log) - apps, err := appResolver.FindApplicationNames(ctx, comment.Command, event) - if err != nil { - h.Log.Err(err, "unable to resolve app name") - return nil - } - - comment.Command.Applications = apps - } - - if comment.Command.Name == command.Plan { - for _, app := range comment.Command.Applications { - var err error = nil - - plan, diff, err := h.Plan(ctx, app, event.Revision) - if err != nil { - h.Log.Err(err, fmt.Sprintf("unable to plan: %s", plan)) - return err - } - var msg string - if diff { - msg = fmt.Sprintf("argocd plan for `%s`\n\n", app) + "```diff\n" + plan + "\n```" - } else { - msg = "no diff detected, current state is up to date with this revision." - h.Log.Info(plan) - } - - err = h.CreateComment(client, ctx, event, msg, comment.Command.Name.String()) - if err != nil { - h.Log.Err(err, fmt.Sprintf("error while planning %s", app)) - } - } - return nil - } - - // TODO allow autoapply - if comment.Command.Name == command.Apply { - go func() { - apply := NewApplyRunner(client, h.Config, h.Log, &h.ArgoClient) - response, err := apply.Run(ctx, comment.Command, event) - if err != nil { - h.Log.Err(err, "unable to apply") - - return - } - msg := fmt.Sprintf("apply result for `%s`\n\n", comment.Command.Application) + "```\n" + response.Message + "\n```" - h.CreateComment(client, ctx, event, msg, comment.Command.Name.String()) - }() - return nil - } - - return errors.Errorf("unsupported argo command") -} - -// TODO: this is just temporary while i build the proof of concept -func (h *PRCommentHandler) Plan(ctx context.Context, name string, revision string) (string, bool, error) { - var plan string - var diff bool = false - var resources *application.ManagedResourcesResponse - - resources, err := h.ArgoClient.ManagedResources(name) - - if err != nil { - return plan, diff, err - } - - live, err := h.ArgoClient.Get(name) - - if err != nil { - return plan, diff, err - } - - target, err := h.ArgoClient.GetManifest(name, revision) - if err != nil { - return plan, diff, err - } - - settings, err := h.ArgoClient.GetSettings() - if err != nil { - return plan, diff, err - } - - return argocd.Plan(ctx, &settings, live, resources, target, revision, h.Log) -} - -// TODO move this to another module -func (h *PRCommentHandler) CreateComment(client *github.Client, ctx context.Context, event vsc.Event, comment string, command string) error { - h.Log.Debug("Creating comment on GitHub pull request %d", event.PullRequest.Number) - var sepStart string - - sepEnd := "\n```\n" + - "\n
\n\n**Warning**: Output length greater than max comment size. Continued in next comment." - - if command != "" { - sepStart = fmt.Sprintf("Continued %s output from previous comment.\n
Show Output\n\n", command) + - "```diff\n" - } else { - sepStart = "Continued from previous comment.\n
Show Output\n\n" + - "```diff\n" - } - - truncationHeader := "\n```\n
" + - "\n
\n\n**Warning**: Command output is larger than the maximum number of comments per command. Output truncated.\n\n[..]\n" - - comments := utils.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 100, truncationHeader) - for i := range comments { - _, resp, err := client.Issues.CreateComment(ctx, event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, &github.IssueComment{Body: &comments[i]}) - if resp != nil { - h.Log.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, resp.StatusCode) - } - if err != nil { - return err - } - } - return nil -} diff --git a/pkg/events/comment_handler_test.go b/pkg/events/comment_handler_test.go deleted file mode 100644 index b3adf69..0000000 --- a/pkg/events/comment_handler_test.go +++ /dev/null @@ -1 +0,0 @@ -package events diff --git a/pkg/events/comment_parser.go b/pkg/events/comment_parser.go index eebf0ac..878b7ce 100644 --- a/pkg/events/comment_parser.go +++ b/pkg/events/comment_parser.go @@ -176,9 +176,9 @@ func (c *CommentParser) Parse(event vsc.Event) *CommentParseResult { return &CommentParseResult{ Command: &CommentCommand{ - Name: name, - Application: app, - Applications: []string{app}, + Name: name, + Applications: []string{app}, + ExplicitApplication: true, }, } } diff --git a/pkg/events/comment_parser_test.go b/pkg/events/comment_parser_test.go index b5f6be2..b60566d 100644 --- a/pkg/events/comment_parser_test.go +++ b/pkg/events/comment_parser_test.go @@ -1,11 +1,13 @@ package events import ( + "encoding/json" "testing" "github.com/corymurphy/argobot/pkg/command" - "github.com/corymurphy/argobot/pkg/github" + vsc "github.com/corymurphy/argobot/pkg/github" "github.com/corymurphy/argobot/pkg/logging" + "github.com/google/go-github/v53/github" ) func Test_Comment_IsHelp(t *testing.T) { @@ -25,10 +27,9 @@ func Test_Comment_IsHelp(t *testing.T) { } } ` - event, err := github.NewEvent("issue_comment", []byte(serialized)) - if err != nil { - t.Error(err) - } + var comment github.IssueCommentEvent + json.Unmarshal([]byte(serialized), &comment) + event := vsc.InitializeFromIssueComment(comment, "") parser := NewCommentParser(logging.NewLogger(logging.Silent)) result := parser.Parse(event) @@ -54,10 +55,9 @@ func Test_Comment_IsBot(t *testing.T) { } } ` - event, err := github.NewEvent("issue_comment", []byte(serialized)) - if err != nil { - t.Error(err) - } + var comment github.IssueCommentEvent + json.Unmarshal([]byte(serialized), &comment) + event := vsc.InitializeFromIssueComment(comment, "") parser := NewCommentParser(logging.NewLogger(logging.Silent)) result := parser.Parse(event) @@ -85,10 +85,9 @@ func Test_PlanHasApplicationName(t *testing.T) { } ` - event, err := github.NewEvent("issue_comment", []byte(serialized)) - if err != nil { - t.Error(err) - } + var comment github.IssueCommentEvent + json.Unmarshal([]byte(serialized), &comment) + event := vsc.InitializeFromIssueComment(comment, "") parser := NewCommentParser(logging.NewLogger(logging.Silent)) result := parser.Parse(event) @@ -96,9 +95,9 @@ func Test_PlanHasApplicationName(t *testing.T) { t.Errorf("expected %s, got %s", command.Help, &result.Command.Name) } - if result.Command.Application != "myapp" { + if result.Command.Applications[0] != "myapp" { t.Log(result.Command) - t.Errorf("expected %s, got %s", "myapp", result.Command.Application) + t.Errorf("expected %s, got %s", "myapp", result.Command.Applications[0]) } } @@ -120,10 +119,9 @@ func Test_ApplyHasApplicationName(t *testing.T) { } ` - event, err := github.NewEvent("issue_comment", []byte(serialized)) - if err != nil { - t.Error(err) - } + var comment github.IssueCommentEvent + json.Unmarshal([]byte(serialized), &comment) + event := vsc.InitializeFromIssueComment(comment, "") parser := NewCommentParser(logging.NewLogger(logging.Silent)) result := parser.Parse(event) @@ -131,8 +129,8 @@ func Test_ApplyHasApplicationName(t *testing.T) { t.Errorf("expected %s, got %s", command.Apply, &result.Command.Name) } - if result.Command.Application != "myapp" { + if result.Command.Applications[0] != "myapp" { t.Log(result.Command) - t.Errorf("expected %s, got %s", "myapp", result.Command.Application) + t.Errorf("expected %s, got %s", "myapp", result.Command.Applications[0]) } } diff --git a/pkg/events/issue_comment_handler.go b/pkg/events/issue_comment_handler.go new file mode 100644 index 0000000..f545111 --- /dev/null +++ b/pkg/events/issue_comment_handler.go @@ -0,0 +1,134 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/corymurphy/argobot/pkg/argocd" + "github.com/corymurphy/argobot/pkg/command" + "github.com/corymurphy/argobot/pkg/env" + vsc "github.com/corymurphy/argobot/pkg/github" + "github.com/corymurphy/argobot/pkg/logging" + "github.com/google/go-github/v53/github" + "github.com/palantir/go-githubapp/githubapp" + "github.com/pkg/errors" +) + +var validIssueCommentActions = []string{"created"} + +type IssueCommentHandler struct { + githubapp.ClientCreator + Config *env.Config + Log logging.SimpleLogging + ArgoClient argocd.ApplicationsClient +} + +func (h *IssueCommentHandler) Handles() []string { + return []string{"issue_comment"} +} + +func (h *IssueCommentHandler) Handle(ctx context.Context, eventType string, deliveryID string, payload []byte) error { + var issue github.IssueCommentEvent + + if err := json.Unmarshal(payload, &issue); err != nil { + h.Log.Err(err, "invalid github event payload") + return fmt.Errorf("invalid github event payload") + } + + installationID := githubapp.GetInstallationIDFromEvent(&issue) + client, err := h.NewInstallationClient(installationID) + if err != nil { + return err + } + + pr, _, err := client.PullRequests.Get(ctx, issue.GetRepo().GetOwner().GetLogin(), issue.GetRepo().GetName(), issue.Issue.GetNumber()) + if err != nil { + h.Log.Err(err, "unable to get revision from pull request") + return nil + } + event := vsc.InitializeFromIssueComment(issue, *pr.GetHead().SHA) + + comment := NewCommentParser(h.Log).Parse(event) + if (comment.Ignore || comment.ImmediateResponse) && !comment.HasResponseComment { + h.Log.Debug("ignoring comment") + return nil + } + + if (comment.Ignore || comment.ImmediateResponse) && comment.HasResponseComment { + prComment := github.IssueComment{ + Body: &comment.CommentResponse, + } + + if _, _, err := client.Issues.CreateComment(ctx, event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, &prComment); err != nil { + return err + } + } + + var apps []string + if comment.Command.ExplicitApplication { + apps = comment.Command.Applications + } else { + resolver := NewApplicationResolver(client, &h.ArgoClient, h.Log) + apps, err = resolver.FindApplicationNames(ctx, event) + if err != nil { + h.Log.Debug("pull request did not change any applications managed by argocd") + return nil + } + } + + commenter := vsc.NewCommenter(client, h.Log, ctx) + + if comment.Command.Name == command.Plan { + planner := argocd.NewPlanner(&h.ArgoClient, h.Log) + + for _, app := range apps { + var err error = nil + + plan, diff, err := planner.Plan(ctx, app, event.Revision) + if err != nil { + h.Log.Err(err, fmt.Sprintf("unable to plan: %s", plan)) + return err + } + var comment string + if diff { + comment = fmt.Sprintf("argocd plan for `%s`\n\n", app) + "```diff\n" + plan + "\n```" + } else { + comment = "no diff detected, current state is up to date with this revision." + h.Log.Info(plan) + } + + commenter.Plan(&event, app, command.Plan.String(), comment) + if err != nil { + h.Log.Err(err, fmt.Sprintf("error while planning %s", app)) + } + } + return nil + } + + if comment.Command.Name == command.Apply { + // TODO: allow for multiple apps to be applied, I want to be careful about this + if len(apps) != 1 { + h.Log.Info("requested apply with more than 1 app, only one app allowed when applying") + return nil + } + go func() { + for _, app := range apps { + applyContext, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + apply := NewApplyRunner(client, h.Config, h.Log, &h.ArgoClient) + response, err := apply.Run(applyContext, app, event) + if err != nil { + h.Log.Err(err, "unable to apply") + return + } + comment := fmt.Sprintf("apply result for `%s`\n\n", app) + "```\n" + response.Message + "\n```" + commenter.Comment(&event, &comment) + } + }() + return nil + } + + return errors.Errorf("unsupported argo command") +} diff --git a/pkg/events/pull_request_handler.go b/pkg/events/pull_request_handler.go new file mode 100644 index 0000000..54f6142 --- /dev/null +++ b/pkg/events/pull_request_handler.go @@ -0,0 +1,81 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/corymurphy/argobot/pkg/argocd" + "github.com/corymurphy/argobot/pkg/command" + "github.com/corymurphy/argobot/pkg/env" + vsc "github.com/corymurphy/argobot/pkg/github" + "github.com/corymurphy/argobot/pkg/logging" + "github.com/corymurphy/argobot/pkg/utils" + "github.com/google/go-github/v53/github" + "github.com/palantir/go-githubapp/githubapp" + "github.com/pkg/errors" +) + +var validPullActions = []string{"opened", "reopened", "ready_for_review"} + +type PullRequestHandler struct { + githubapp.ClientCreator + Config *env.Config + Log logging.SimpleLogging + ArgoClient argocd.ApplicationsClient +} + +func (h *PullRequestHandler) Handles() []string { + return []string{"pull_request"} +} + +func (h *PullRequestHandler) Handle(ctx context.Context, eventType string, deliveryID string, payload []byte) error { + + var pull github.PullRequestEvent + if err := json.Unmarshal(payload, &pull); err != nil { + h.Log.Err(err, "invalid github event payload") + return fmt.Errorf("invalid github event payload") + } + + if !utils.StringInSlice(*pull.Action, validPullActions) { + h.Log.Debug("ignoring pull request action %s", *pull.Action) + return nil + } + + event := vsc.InitializeFromPullRequest(pull) + installationID := githubapp.GetInstallationIDFromEvent(&pull) + client, err := h.NewInstallationClient(installationID) + if err != nil { + return err + } + + resolver := NewApplicationResolver(client, &h.ArgoClient, h.Log) + apps, err := resolver.FindApplicationNames(ctx, event) + if err != nil { + h.Log.Debug("pull request did not change any applications managed by argocd") + return nil + } + + commenter := vsc.NewCommenter(client, h.Log, ctx) + planner := argocd.NewPlanner(&h.ArgoClient, h.Log) + + for _, app := range apps { + + plan, diff, err := planner.Plan(ctx, app, event.Revision) + if err != nil { + h.Log.Err(err, fmt.Sprintf("unable to plan: %s", plan)) + return err + } + + var comment string + if diff { + comment = fmt.Sprintf("argocd plan for `%s`\n\n", app) + "```diff\n" + plan + "\n```" + } else { + comment = "no diff detected, current state is up to date with this revision." + } + + commenter.Plan(&event, app, command.Plan.String(), comment) + } + + return errors.Errorf("unsupported argo command") +} diff --git a/pkg/github/commenter.go b/pkg/github/commenter.go new file mode 100644 index 0000000..757e0f8 --- /dev/null +++ b/pkg/github/commenter.go @@ -0,0 +1,69 @@ +package github + +import ( + "context" + "fmt" + + "github.com/corymurphy/argobot/pkg/logging" + "github.com/corymurphy/argobot/pkg/utils" + "github.com/google/go-github/v53/github" +) + +const maxCommentLength = 32768 + +type Commenter struct { + client *github.Client + log logging.SimpleLogging + ctx context.Context +} + +func NewCommenter(client *github.Client, log logging.SimpleLogging, ctx context.Context) *Commenter { + return &Commenter{ + client: client, + log: log, + ctx: ctx, + } +} + +func (c *Commenter) Plan(event *Event, app string, command string, comment string) error { + c.log.Debug("Creating comment on GitHub pull request %d", event.PullRequest.Number) + var sepStart string + + sepEnd := "\n```\n
" + + "\n
\n\n**Warning**: Output length greater than max comment size. Continued in next comment." + + if command != "" { + sepStart = fmt.Sprintf("Continued %s output from previous comment.\n
Show Output\n\n", command) + + "```diff\n" + } else { + sepStart = "Continued from previous comment.\n
Show Output\n\n" + + "```diff\n" + } + + truncationHeader := "\n```\n
" + + "\n
\n\n**Warning**: Command output is larger than the maximum number of comments per command. Output truncated.\n\n[..]\n" + + comments := utils.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 100, truncationHeader) + for i := range comments { + _, resp, err := c.client.Issues.CreateComment(c.ctx, event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, &github.IssueComment{Body: &comments[i]}) + if resp != nil { + c.log.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, resp.StatusCode) + } + if err != nil { + return err + } + } + + return nil +} + +func (c *Commenter) Comment(event *Event, comment *string) error { + _, resp, err := c.client.Issues.CreateComment(c.ctx, event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, &github.IssueComment{Body: comment}) + if resp != nil { + c.log.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", event.Repository.Owner, event.Repository.Name, event.PullRequest.Number, resp.StatusCode) + } + if err != nil { + return err + } + return err +} diff --git a/pkg/github/event_metadata.go b/pkg/github/event_metadata.go index 5440531..4d8f57c 100644 --- a/pkg/github/event_metadata.go +++ b/pkg/github/event_metadata.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/google/go-github/v53/github" + "github.com/palantir/go-githubapp/githubapp" "github.com/pkg/errors" ) @@ -55,6 +56,7 @@ type Event struct { Repository Repository PullRequest PullRequest InstallationProvider InstallationProvider + GithubClient *github.Client } func (e *Event) GetInstallation() *github.Installation { @@ -65,7 +67,7 @@ func (e *Event) HasMessage() bool { return e.Message != "" } -func NewEvent(eventType string, payload []byte) (Event, error) { +func NewEvent(clientCreator githubapp.ClientCreator, eventType string, payload []byte) (Event, error) { var event Event var githubEvent GithubEvent if err := json.Unmarshal(payload, &githubEvent); err != nil { @@ -77,6 +79,10 @@ func NewEvent(eventType string, payload []byte) (Event, error) { if err := json.Unmarshal(payload, &comment); err != nil { return event, errors.Wrap(err, "failed to parse issue comment event payload") } + // comment. + + // comment.GetChanges().Base.SHA.From + // comment.Re return Event{ Actor: Actor{Name: comment.GetComment().GetUser().GetLogin()}, @@ -104,6 +110,7 @@ func NewEvent(eventType string, payload []byte) (Event, error) { Actor: Actor{Name: pr.GetPullRequest().GetUser().GetLogin()}, Action: Opened, IsPullRequest: true, + Revision: *pr.PullRequest.Head.SHA, Repository: Repository{ Name: pr.GetRepo().GetName(), Owner: pr.GetRepo().GetOwner().GetLogin(), @@ -118,3 +125,38 @@ func NewEvent(eventType string, payload []byte) (Event, error) { return event, fmt.Errorf("unsupported event %s %s", eventType, *githubEvent.Action) } + +func InitializeFromIssueComment(source github.IssueCommentEvent, revision string) Event { + return Event{ + Actor: Actor{Name: source.GetComment().GetUser().GetLogin()}, + Action: Comment, + IsPullRequest: source.GetIssue().IsPullRequest(), + Revision: revision, + Repository: Repository{ + Name: source.GetRepo().GetName(), + Owner: source.GetRepo().GetOwner().GetLogin(), + }, + PullRequest: PullRequest{ + Number: source.GetIssue().GetNumber(), + }, + Message: *source.GetComment().Body, + InstallationProvider: &source, + } +} + +func InitializeFromPullRequest(source github.PullRequestEvent) Event { + return Event{ + Actor: Actor{Name: source.GetPullRequest().GetUser().GetLogin()}, + Action: Opened, + IsPullRequest: true, + Revision: *source.PullRequest.Head.SHA, + Repository: Repository{ + Name: source.GetRepo().GetName(), + Owner: source.GetRepo().GetOwner().GetLogin(), + }, + PullRequest: PullRequest{ + Number: source.GetPullRequest().GetNumber(), + }, + Message: "", + } +} diff --git a/pkg/logging/simple_logging.go b/pkg/logging/simple_logging.go index 3050c17..ce75208 100644 --- a/pkg/logging/simple_logging.go +++ b/pkg/logging/simple_logging.go @@ -1,6 +1,7 @@ package logging import ( + "fmt" "log" "github.com/pkg/errors" @@ -25,6 +26,26 @@ type Logger struct { level int } +func GetLogLevel(input string) (int, error) { + // level, err := strconv.Atoi(input) + // if err != nil { + // return level, err + // } + switch input { + case "silent": + return Silent, nil + case "error": + return Err, nil + case "warn": + return Warn, nil + case "info": + return Info, nil + case "debug": + return Debug, nil + } + return Info, fmt.Errorf("invalid log level %s", input) +} + func NewLogger(level int) *Logger { return &Logger{ level: level, diff --git a/pkg/server/server.go b/pkg/server/server.go index 786431e..d0a1d32 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -14,12 +14,12 @@ import ( ) type Server struct { - Config *env.Config - Log logging.SimpleLogging - PRCommentHandler http.Handler - PlanClient argocd.PlanClient - ApplyClient argocd.ApplyClient - ArgoClient argocd.ApplicationsClient + Config *env.Config + Log logging.SimpleLogging + WebhookHandler http.Handler + PlanClient argocd.PlanClient + ApplyClient argocd.ApplyClient + ArgoClient argocd.ApplicationsClient githubapp.ClientCreator } @@ -37,20 +37,27 @@ func NewServer(config *env.Config, logger logging.SimpleLogging, argoClient *arg panic(err) } - prCommentHandler := &events.PRCommentHandler{ + issueCommentHandler := &events.IssueCommentHandler{ ClientCreator: cc, Config: config, Log: logger, ArgoClient: *argoClient, } + pullRequestHandler := &events.PullRequestHandler{ + ClientCreator: cc, + Config: config, + Log: logger, + ArgoClient: *argoClient, + } + + webhookHandler := githubapp.NewDefaultEventDispatcher(config.Github, issueCommentHandler, pullRequestHandler) - webhookHandler := githubapp.NewDefaultEventDispatcher(config.Github, prCommentHandler) return &Server{ - Config: config, - Log: logger, - ClientCreator: cc, - PRCommentHandler: webhookHandler, - ArgoClient: *argoClient, + Config: config, + Log: logger, + ClientCreator: cc, + WebhookHandler: webhookHandler, + ArgoClient: *argoClient, } } @@ -64,7 +71,7 @@ func (s *Server) Health(w http.ResponseWriter, r *http.Request) { func (s *Server) Start() { http.Handle("/health", http.HandlerFunc(s.Health)) - http.Handle(githubapp.DefaultWebhookRoute, s.PRCommentHandler) + http.Handle(githubapp.DefaultWebhookRoute, s.WebhookHandler) addr := fmt.Sprintf("%s:%d", s.Config.Server.Address, s.Config.Server.Port) s.Log.Info("starting server on %s", addr) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 418a8bc..786ccb3 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -77,7 +77,7 @@ func Test_PRCommentHandler(t *testing.T) { req := NewWebhookRequest(testCase.BodyPath, testCase.Config) - s.PRCommentHandler.ServeHTTP(w, req) + s.WebhookHandler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Result().StatusCode) } diff --git a/pkg/utils/lists.go b/pkg/utils/lists.go new file mode 100644 index 0000000..bcde9f5 --- /dev/null +++ b/pkg/utils/lists.go @@ -0,0 +1,10 @@ +package utils + +func StringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} diff --git a/test/dev/install_test.go b/test/dev/install_test.go index 580e98a..31dd73c 100644 --- a/test/dev/install_test.go +++ b/test/dev/install_test.go @@ -44,8 +44,9 @@ func Test_LocalDevelopmentInstall(t *testing.T) { options := &helm.Options{ KubectlOptions: kubectlOptions, SetValues: map[string]string{ - "chartVersion": strings.TrimSpace(version), - "image.tag": tag, + "chartVersion": strings.TrimSpace(version), + "image.tag": tag, + "webServer.logLevel": "debug", }, ExtraArgs: map[string][]string{ "upgrade": {"--timeout", "15s", "--install", "--wait-for-jobs", "--wait", "--create-namespace", "--namespace", namespace}, diff --git a/version b/version index a551051..04a373e 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.15.0 +0.16.0