diff --git a/docs/services/github.md b/docs/services/github.md index be76ab15..f41c2550 100644 --- a/docs/services/github.md +++ b/docs/services/github.md @@ -89,3 +89,84 @@ template.app-deployed: | Setting this option to `false` is required if you would like to deploy older refs in your default branch. For more information see the [GitHub Deployment API Docs](https://docs.github.com/en/rest/deployments/deployments?apiVersion=2022-11-28#create-a-deployment). - If `github.pullRequestComment.content` is set to 65536 characters or more, it will be truncated. + +# Supported API + +## Status +[Api Docs](https://docs.github.com/en/rest/commits/statuses) + +### Example +```yaml +template.app-deployed: | + message: | + Application {{.app.metadata.name}} is now running new version of deployments manifests. + github: + repoURLPath: "{{.app.spec.source.repoURL}}" + revisionPath: "{{.app.status.operationState.syncResult.revision}}" + status: + state: success + label: "continuous-delivery/{{.app.metadata.name}}" + targetURL: "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" +``` +## Deployment +[Api Docs](https://docs.github.com/en/rest/deployments/deployments) +### Example +```yaml +template.app-deployed: | + github: + repoURLPath: "{{.app.spec.source.repoURL}}" + revisionPath: "{{.app.status.operationState.syncResult.revision}}" + deployment: + state: success + environment: production + environmentURL: "https://{{.app.metadata.name}}.example.com" + logURL: "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true" + requiredContexts: [] + autoMerge: true +``` +## PullRequestComment +[Api Docs](https://docs.github.com/en/rest/issues/comments#create-an-issue-comment) +### Example +```yaml +template.app-deployed: | + github: + repoURLPath: "{{.app.spec.source.repoURL}}" + revisionPath: "{{.app.status.operationState.syncResult.revision}}" + pullRequestComment: + content: | + Application {{.app.metadata.name}} is now running new version of deployments manifests. + See more here: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}?operation=true +``` +## CheckRuns +[Api Docs](https://docs.github.com/en/rest/checks/runs) +### Example + +Create new checkrun for the current commit: + +```yaml +template.app-deployed: | + github: + repoURLPath: "{{.app.spec.source.repoURL}}" + revisionPath: "{{.app.status.operationState.syncResult.revision}}" + checkRun: + name: "Deployment" + status: "{{if and (eq .app.status.operationState.phase "Succeeded") (eq .app.status.health.status "Healthy")}}"success"{{else}}"failure"{{end}}" + completed_at: "{{ (call .time.Now).Format "2006-01-02T15:04:05Z07:00" }}", + conclusion: "completed" +``` + +Update existing checkrun: + +```yaml +template.app-deployed: | + github: + repoURLPath: "{{.app.spec.source.repoURL}}" + revisionPath: "not used" + checkRun: + name: "Deployment" + id: "{{ (call .repo.GetAppDetails).Helm.GetParameterValueByName "git_check_id" }}" + status: "{{if and (eq .app.status.operationState.phase "Succeeded") (eq .app.status.health.status "Healthy")}}"success"{{else}}"failure"{{end}}" + details_url": "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}?", + completed_at: "{{ (call .time.Now).Format "2006-01-02T15:04:05Z07:00" }}", + conclusion: "completed" +``` \ No newline at end of file diff --git a/pkg/services/github.go b/pkg/services/github.go index c8f5d4cd..f92c0d8b 100644 --- a/pkg/services/github.go +++ b/pkg/services/github.go @@ -6,8 +6,10 @@ import ( "fmt" "net/http" "regexp" + "strconv" "strings" texttemplate "text/template" + "time" "unicode/utf8" "github.com/bradleyfalzon/ghinstallation/v2" @@ -37,6 +39,7 @@ type GitHubNotification struct { Status *GitHubStatus `json:"status,omitempty"` Deployment *GitHubDeployment `json:"deployment,omitempty"` PullRequestComment *GitHubPullRequestComment `json:"pullRequestComment,omitempty"` + CheckRun *GitHubCheckRun `json:"checkRun,omitempty"` RepoURLPath string `json:"repoURLPath,omitempty"` RevisionPath string `json:"revisionPath,omitempty"` } @@ -47,6 +50,19 @@ type GitHubStatus struct { TargetURL string `json:"targetURL,omitempty"` } +type GitHubCheckRun struct { + //copy of github:UpdateCheckRunOptions + id + timestamp as string + ID string `json:"id"` // check_id, actually an int64, but string since we want it to be template'able. (Optional - create new check-run for revision if missing.) + Name string `json:"name"` // The name of the check (e.g., "code-coverage"). (Required.) + DetailsURL string `json:"details_url,omitempty"` // The URL of the integrator's site that has the full details of the check. (Optional.) + ExternalID string `json:"external_id,omitempty"` // A reference for the run on the integrator's system. (Optional.) + Status string `json:"status,omitempty"` // The current status. Can be one of "queued", "in_progress", or "completed". Default: "queued". (Optional.) + Conclusion string `json:"conclusion,omitempty"` // Can be one of "success", "failure", "neutral", "cancelled", "skipped", "timed_out", or "action_required". (Optional. Required if you provide a status of "completed".) + CompletedAt string `json:"completed_at,omitempty"` // The time the check completed. (Optional. Required if you provide conclusion.) + Output *github.CheckRunOutput `json:"output,omitempty"` // Provide descriptive details about the run. (Optional) + Actions []*github.CheckRunAction `json:"actions,omitempty"` // Possible further actions the integrator can perform, which a user may trigger. (Optional.) +} + type GitHubDeployment struct { State string `json:"state,omitempty"` Environment string `json:"environment,omitempty"` @@ -65,6 +81,40 @@ const ( revisionTemplate = "{{.app.status.operationState.syncResult.revision}}" ) +type apiField struct { + G func(*GitHubNotification) *string + S func(*GitHubNotification, string) +} +type tmplSetter struct { + S func(*GitHubNotification, string) + T *texttemplate.Template +} + +func api(templates *[]tmplSetter, g *GitHubNotification, name string, f texttemplate.FuncMap, creatorCheck func(*GitHubNotification) bool, apiCreator func(*GitHubNotification, string), fields []apiField) error { + if !creatorCheck(g) { + return nil + } + //create the object that holds this api, the text template is just a dummy so we can generalize the code + tmpl, err := texttemplate.New(name).Funcs(f).Parse("") + if err != nil { + return err + } + *templates = append(*templates, tmplSetter{S: apiCreator, T: tmpl}) + //create the template for each field + for _, field := range fields { + templateStr := field.G(g) + if templateStr == nil { + continue + } + tmpl, err := texttemplate.New(name).Funcs(f).Parse(*templateStr) + if err != nil { + return err + } + *templates = append(*templates, tmplSetter{S: field.S, T: tmpl}) + } + return nil +} + func (g *GitHubNotification) GetTemplater(name string, f texttemplate.FuncMap) (Templater, error) { if g.RepoURLPath == "" { g.RepoURLPath = repoURLtemplate @@ -73,63 +123,65 @@ func (g *GitHubNotification) GetTemplater(name string, f texttemplate.FuncMap) ( g.RevisionPath = revisionTemplate } - repoURL, err := texttemplate.New(name).Funcs(f).Parse(g.RepoURLPath) - if err != nil { - return nil, err - } + //list of template'able fields + templates := []tmplSetter{} - revision, err := texttemplate.New(name).Funcs(f).Parse(g.RevisionPath) - if err != nil { - return nil, err + //common api + if err := api(&templates, g, name, f, func(x *GitHubNotification) bool { return true }, func(x *GitHubNotification, v string) {}, []apiField{ + {G: func(x *GitHubNotification) *string { return &x.RepoURLPath }, S: func(x *GitHubNotification, val string) { x.repoURL = val }}, + {G: func(x *GitHubNotification) *string { return &x.RevisionPath }, S: func(x *GitHubNotification, val string) { x.revision = val }}, + }); err != nil { + return nil,err } - var statusState, label, targetURL *texttemplate.Template - if g.Status != nil { - statusState, err = texttemplate.New(name).Funcs(f).Parse(g.Status.State) - if err != nil { - return nil, err - } - - label, err = texttemplate.New(name).Funcs(f).Parse(g.Status.Label) - if err != nil { - return nil, err - } - - targetURL, err = texttemplate.New(name).Funcs(f).Parse(g.Status.TargetURL) - if err != nil { - return nil, err - } + //Status api + if err := api(&templates, g, name, f, func(x *GitHubNotification) bool { return x.Status != nil }, func(x *GitHubNotification, v string) { x.Status = &GitHubStatus{} }, []apiField{ + {G: func(x *GitHubNotification) *string { return &x.Status.State }, S: func(x *GitHubNotification, val string) { x.Status.State = val }}, + {G: func(x *GitHubNotification) *string { return &x.Status.Label }, S: func(x *GitHubNotification, val string) { x.Status.Label = val }}, + {G: func(x *GitHubNotification) *string { return &x.Status.TargetURL }, S: func(x *GitHubNotification, val string) { x.Status.TargetURL = val }}, + }); err != nil { + return nil,err } - var deploymentState, environment, environmentURL, logURL *texttemplate.Template - if g.Deployment != nil { - deploymentState, err = texttemplate.New(name).Funcs(f).Parse(g.Deployment.State) - if err != nil { - return nil, err - } - - environment, err = texttemplate.New(name).Funcs(f).Parse(g.Deployment.Environment) - if err != nil { - return nil, err - } + //Deployment api + if err := api(&templates, g, name, f, func(x *GitHubNotification) bool { return x.Deployment != nil }, func(x *GitHubNotification, v string) { x.Deployment = &GitHubDeployment{} }, []apiField{ + {G: func(x *GitHubNotification) *string { return &x.Deployment.State }, S: func(x *GitHubNotification, val string) { x.Deployment.State = val }}, + {G: func(x *GitHubNotification) *string { return &x.Deployment.Environment }, S: func(x *GitHubNotification, val string) { x.Deployment.Environment = val }}, + {G: func(x *GitHubNotification) *string { return &x.Deployment.EnvironmentURL }, S: func(x *GitHubNotification, val string) { x.Deployment.EnvironmentURL = val }}, + {G: func(x *GitHubNotification) *string { return &x.Deployment.LogURL }, S: func(x *GitHubNotification, val string) { x.Deployment.LogURL = val }}, + }); err != nil { + return nil,err + } - environmentURL, err = texttemplate.New(name).Funcs(f).Parse(g.Deployment.EnvironmentURL) - if err != nil { - return nil, err - } + //PullRequestComment api + if err := api(&templates, g, name, f, func(x *GitHubNotification) bool { return x.PullRequestComment != nil }, func(x *GitHubNotification, v string) { x.PullRequestComment = &GitHubPullRequestComment{} }, []apiField{ + {G: func(x *GitHubNotification) *string { return &x.PullRequestComment.Content }, S: func(x *GitHubNotification, val string) { x.PullRequestComment.Content = val }}, + }); err != nil { + return nil,err + } - logURL, err = texttemplate.New(name).Funcs(f).Parse(g.Deployment.LogURL) - if err != nil { - return nil, err - } + //CheckRunUpdate api + if err := api(&templates, g, name, f, func(x *GitHubNotification) bool { return x.CheckRun != nil }, func(x *GitHubNotification, v string) { x.CheckRun = &GitHubCheckRun{} }, []apiField{ + {G: func(x *GitHubNotification) *string { return &x.CheckRun.ID }, S: func(x *GitHubNotification, val string) { x.CheckRun.ID = val }}, + {G: func(x *GitHubNotification) *string { return &x.CheckRun.Name }, S: func(x *GitHubNotification, val string) { x.CheckRun.Name = val }}, + {G: func(x *GitHubNotification) *string { return &x.CheckRun.DetailsURL }, S: func(x *GitHubNotification, val string) { x.CheckRun.DetailsURL = val }}, + {G: func(x *GitHubNotification) *string { return &x.CheckRun.ExternalID }, S: func(x *GitHubNotification, val string) { x.CheckRun.ExternalID = val }}, + {G: func(x *GitHubNotification) *string { return &x.CheckRun.Status }, S: func(x *GitHubNotification, val string) { x.CheckRun.Status = val }}, + {G: func(x *GitHubNotification) *string { return &x.CheckRun.Conclusion }, S: func(x *GitHubNotification, val string) { x.CheckRun.Conclusion = val }}, + {G: func(x *GitHubNotification) *string { return &x.CheckRun.CompletedAt }, S: func(x *GitHubNotification, val string) { x.CheckRun.CompletedAt = val }}, + }); err != nil { + return nil,err } - var pullRequestCommentContent *texttemplate.Template - if g.PullRequestComment != nil { - pullRequestCommentContent, err = texttemplate.New(name).Funcs(f).Parse(g.PullRequestComment.Content) - if err != nil { - return nil, err - } + //CheckRunUpdate.Outout api + if err := api(&templates, g, name, f, func(x *GitHubNotification) bool { return x.CheckRun != nil && x.CheckRun.Output != nil }, func(x *GitHubNotification, v string) { x.CheckRun.Output = &github.CheckRunOutput{} }, []apiField{ + {G: func(x *GitHubNotification) *string { return x.CheckRun.Output.Title }, S: func(x *GitHubNotification, val string) { x.CheckRun.Output.Title = &val }}, + {G: func(x *GitHubNotification) *string { return x.CheckRun.Output.Summary }, S: func(x *GitHubNotification, val string) { x.CheckRun.Output.Summary = &val }}, + {G: func(x *GitHubNotification) *string { return x.CheckRun.Output.Text }, S: func(x *GitHubNotification, val string) { x.CheckRun.Output.Text = &val }}, + {G: func(x *GitHubNotification) *string { return x.CheckRun.Output.Text }, S: func(x *GitHubNotification, val string) { x.CheckRun.Output.Text = &val }}, + {G: func(x *GitHubNotification) *string { return x.CheckRun.Output.AnnotationsURL }, S: func(x *GitHubNotification, val string) { x.CheckRun.Output.AnnotationsURL = &val }}, + }); err != nil { + return nil,err } return func(notification *Notification, vars map[string]interface{}) error { @@ -140,71 +192,16 @@ func (g *GitHubNotification) GetTemplater(name string, f texttemplate.FuncMap) ( } } - var repoData bytes.Buffer - if err := repoURL.Execute(&repoData, vars); err != nil { - return err - } - notification.GitHub.repoURL = repoData.String() - - var revisionData bytes.Buffer - if err := revision.Execute(&revisionData, vars); err != nil { - return err - } - notification.GitHub.revision = revisionData.String() - - if g.Status != nil { - if notification.GitHub.Status == nil { - notification.GitHub.Status = &GitHubStatus{} - } - - var stateData bytes.Buffer - if err := statusState.Execute(&stateData, vars); err != nil { - return err - } - notification.GitHub.Status.State = stateData.String() - - var labelData bytes.Buffer - if err := label.Execute(&labelData, vars); err != nil { + for _, tmplFunc := range templates { + var data bytes.Buffer + if err := tmplFunc.T.Execute(&data, vars); err != nil { return err } - notification.GitHub.Status.Label = labelData.String() - - var targetData bytes.Buffer - if err := targetURL.Execute(&targetData, vars); err != nil { - return err - } - notification.GitHub.Status.TargetURL = targetData.String() + tmplFunc.S(notification.GitHub, data.String()) } + //non-template'able fields if g.Deployment != nil { - if notification.GitHub.Deployment == nil { - notification.GitHub.Deployment = &GitHubDeployment{} - } - - var stateData bytes.Buffer - if err := deploymentState.Execute(&stateData, vars); err != nil { - return err - } - notification.GitHub.Deployment.State = stateData.String() - - var environmentData bytes.Buffer - if err := environment.Execute(&environmentData, vars); err != nil { - return err - } - notification.GitHub.Deployment.Environment = environmentData.String() - - var environmentURLData bytes.Buffer - if err := environmentURL.Execute(&environmentURLData, vars); err != nil { - return err - } - notification.GitHub.Deployment.EnvironmentURL = environmentURLData.String() - - var logURLData bytes.Buffer - if err := logURL.Execute(&logURLData, vars); err != nil { - return err - } - notification.GitHub.Deployment.LogURL = logURLData.String() - if g.Deployment.AutoMerge == nil { deploymentAutoMergeDefault := true notification.GitHub.Deployment.AutoMerge = &deploymentAutoMergeDefault @@ -213,19 +210,13 @@ func (g *GitHubNotification) GetTemplater(name string, f texttemplate.FuncMap) ( } notification.GitHub.Deployment.RequiredContexts = g.Deployment.RequiredContexts } - - if g.PullRequestComment != nil { - if notification.GitHub.PullRequestComment == nil { - notification.GitHub.PullRequestComment = &GitHubPullRequestComment{} - } - - var contentData bytes.Buffer - if err := pullRequestCommentContent.Execute(&contentData, vars); err != nil { - return err + if g.CheckRun != nil { + notification.GitHub.CheckRun.Actions = g.CheckRun.Actions + if g.CheckRun.Output != nil { + notification.GitHub.CheckRun.Output.Annotations = g.CheckRun.Output.Annotations + notification.GitHub.CheckRun.Output.Images = g.CheckRun.Output.Images } - notification.GitHub.PullRequestComment.Content = contentData.String() } - return nil }, nil } @@ -393,5 +384,59 @@ func (g gitHubService) Send(notification Notification, _ Destination) error { } } + if notification.GitHub.CheckRun != nil { + u := strings.Split(fullNameByRepoURL(notification.GitHub.repoURL), "/") + var id int64 + if notification.GitHub.CheckRun.ID != "" { + parsedID, err := strconv.ParseInt(notification.GitHub.CheckRun.ID, 10, 64) + if err != nil { + return err + } + id = parsedID + } + if id == 0 { + checkrun, _, err := g.client.Checks.CreateCheckRun( + context.Background(), + u[0], + u[1], + github.CreateCheckRunOptions{ + Name: notification.GitHub.CheckRun.Name, + HeadSHA: notification.GitHub.revision, + }, + ) + if err != nil { + return err + } + id = *checkrun.ID + } + var timestamp *github.Timestamp + if notification.GitHub.CheckRun.CompletedAt != "" { + parsedTime, err := time.Parse("2006-01-02T15:04:05Z07:00", notification.GitHub.CheckRun.CompletedAt) + if err != nil { + return err + } + timestamp = &github.Timestamp{parsedTime} + } + _, _, err := g.client.Checks.UpdateCheckRun( + context.Background(), + u[0], + u[1], + id, + github.UpdateCheckRunOptions{ + Name: notification.GitHub.CheckRun.Name, + DetailsURL: ¬ification.GitHub.CheckRun.DetailsURL, + ExternalID: ¬ification.GitHub.CheckRun.ExternalID, + Status: ¬ification.GitHub.CheckRun.Status, + Conclusion: ¬ification.GitHub.CheckRun.Conclusion, + CompletedAt: timestamp, + Output: notification.GitHub.CheckRun.Output, + Actions: notification.GitHub.CheckRun.Actions, + }, + ) + if err != nil { + return err + } + } + return nil } diff --git a/pkg/services/github_test.go b/pkg/services/github_test.go index aa191d2d..1c3621c5 100644 --- a/pkg/services/github_test.go +++ b/pkg/services/github_test.go @@ -4,6 +4,7 @@ import ( "testing" "text/template" + "github.com/google/go-github/v41/github" "github.com/stretchr/testify/assert" ) @@ -256,3 +257,50 @@ func TestGetTemplater_Github_PullRequestComment(t *testing.T) { assert.Equal(t, "0123456789", notification.GitHub.revision) assert.Equal(t, "This is a comment", notification.GitHub.PullRequestComment.Content) } + +func TestGetTemplater_Github_CheckRun(t *testing.T) { + title := "{{.sync.status.lastSyncedCommit}}" + n := Notification{ + GitHub: &GitHubNotification{ + RepoURLPath: "{{.sync.spec.git.repo}}", + RevisionPath: "{{.sync.status.lastSyncedCommit}}", + CheckRun: &GitHubCheckRun{ + Output: &github.CheckRunOutput{ + Title: &title, + }, + }, + }, + } + templater, err := n.GetTemplater("", template.FuncMap{}) + + if !assert.NoError(t, err) { + return + } + + var notification Notification + err = templater(¬ification, map[string]interface{}{ + "sync": map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "root-sync-test", + }, + "spec": map[string]interface{}{ + "git": map[string]interface{}{ + "repo": "https://github.com/argoproj-labs/argocd-notifications.git", + }, + }, + "status": map[string]interface{}{ + "lastSyncedCommit": "0123456789", + }, + }, + }) + + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, "{{.sync.spec.git.repo}}", notification.GitHub.RepoURLPath) + assert.Equal(t, "{{.sync.status.lastSyncedCommit}}", notification.GitHub.RevisionPath) + assert.Equal(t, "https://github.com/argoproj-labs/argocd-notifications.git", notification.GitHub.repoURL) + assert.Equal(t, "0123456789", notification.GitHub.revision) + assert.Equal(t, "0123456789", *notification.GitHub.CheckRun.Output.Title) +}