diff --git a/enterprise/dev/deployment-notifier/README.md b/enterprise/dev/deployment-notifier/README.md index ee2c41cd311ee..00a3ea9ee93f8 100644 --- a/enterprise/dev/deployment-notifier/README.md +++ b/enterprise/dev/deployment-notifier/README.md @@ -19,6 +19,8 @@ deployment-notifier -environment $MY_ENV -slack.token=$SLACK_TOKEN -slack.webhoo - `-slack.token` Slack Token used to find the matching Slack handle for pull request authors. - `-slack.webhook` Slack webhook URL to post the notifications on. - `-honeycomb.token` Honeycomb API token that is used to upload deployment traces. +- `-codeowners` (defaults to `false`) computes the codeowners for each PR and annotate the pull requests on Slack with mentions (expensive, it requires a full clone). + - requires `-slack.token` to resolve to Slack handles, will fall back to GitHub handles otherwise. ## How it works diff --git a/enterprise/dev/deployment-notifier/code_owner_resolver.go b/enterprise/dev/deployment-notifier/code_owner_resolver.go new file mode 100644 index 0000000000000..acf2488864425 --- /dev/null +++ b/enterprise/dev/deployment-notifier/code_owner_resolver.go @@ -0,0 +1,108 @@ +package main + +import ( + "bufio" + "bytes" + "os" + "os/exec" + "strings" + "sync" + + "github.com/sourcegraph/codenotify" + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// CodeOwnerResolver returns the list of GitHub users who subscribed themselves +// to changes on particular paths, through the OWNERS file. +// +// See https://github.com/sourcegraph/codenotify for more informations. +type CodeOwnerResolver interface { + Resolve(ref string) ([]string, error) +} + +func NewMockCodeOwnerResolver(mapping map[string][]string) CodeOwnerResolver { + if mapping == nil { + mapping = map[string][]string{} + } + return &mockCodeOwnerResolver{ + mapping: mapping, + } +} + +type mockCodeOwnerResolver struct { + mapping map[string][]string +} + +func (m *mockCodeOwnerResolver) Resolve(ref string) ([]string, error) { + return m.mapping[ref], nil +} + +func NewGitCodeOwnerResolver(cloneURL string, clonePath string) CodeOwnerResolver { + return &gitCodeOwnerResolver{ + cloneURL: cloneURL, + clonePath: clonePath, + } +} + +type gitCodeOwnerResolver struct { + cloneURL string + clonePath string + once sync.Once +} + +func (g *gitCodeOwnerResolver) Resolve(ref string) ([]string, error) { + var err error + g.once.Do(func() { + _, err = exec.Command("git", "clone", "--quiet", "git@github.com:sourcegraph/sourcegraph.git", g.clonePath).Output() + }) + if err != nil { + return nil, err + } + + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + os.Chdir(g.clonePath) + defer func() { _ = os.Chdir(cwd) }() + + owners := []string{} + diff, err := exec.Command("git", "show", "--name-only", ref).CombinedOutput() + if err != nil { + return nil, errors.Wrapf(err, "error getting diff for %s", ref) + } + paths, err := readLines(diff) + if err != nil { + return nil, errors.Errorf("error scanning lines from diff %s: %s\n%s", ref, err, string(diff)) + } + for _, path := range paths { + fs := codenotify.NewGitFS(g.clonePath, ref) + ownersList, err := codenotify.Subscribers(fs, path, "CODENOTIFY") + if err != nil { + return nil, errors.Wrapf(err, "error computing the subscribers for %s", path) + } + owners = append(owners, ownersList...) + } + return uniqAndSanitize(owners), nil +} + +func uniqAndSanitize(strs []string) []string { + set := map[string]struct{}{} + for _, str := range strs { + set[str] = struct{}{} + } + uniqStrs := make([]string, 0, len(set)) + for str := range set { + uniqStrs = append(uniqStrs, strings.TrimLeft(str, "@")) + } + return uniqStrs +} + +func readLines(b []byte) ([]string, error) { + lines := []string{} + scanner := bufio.NewScanner(bytes.NewBuffer(b)) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} diff --git a/enterprise/dev/deployment-notifier/deployment-notifier b/enterprise/dev/deployment-notifier/deployment-notifier new file mode 100755 index 0000000000000..26fe1cdd1887b Binary files /dev/null and b/enterprise/dev/deployment-notifier/deployment-notifier differ diff --git a/enterprise/dev/deployment-notifier/manifest_differ.go b/enterprise/dev/deployment-notifier/deployment_differ.go similarity index 99% rename from enterprise/dev/deployment-notifier/manifest_differ.go rename to enterprise/dev/deployment-notifier/deployment_differ.go index 87a22807c6e2d..5739632667161 100644 --- a/enterprise/dev/deployment-notifier/manifest_differ.go +++ b/enterprise/dev/deployment-notifier/deployment_differ.go @@ -22,6 +22,7 @@ type ServiceVersionDiff struct { type DeploymentDiffer interface { Services() (map[string]*ServiceVersionDiff, error) } + type manifestDeploymentDiffer struct { changedFiles []string diffs map[string]*ServiceVersionDiff diff --git a/enterprise/dev/deployment-notifier/manifest_differ_test.go b/enterprise/dev/deployment-notifier/deployment_differ_test.go similarity index 100% rename from enterprise/dev/deployment-notifier/manifest_differ_test.go rename to enterprise/dev/deployment-notifier/deployment_differ_test.go diff --git a/enterprise/dev/deployment-notifier/deployment_notifier.go b/enterprise/dev/deployment-notifier/deployment_notifier.go index a9d85eaba380a..aaeb2784e5e3b 100644 --- a/enterprise/dev/deployment-notifier/deployment_notifier.go +++ b/enterprise/dev/deployment-notifier/deployment_notifier.go @@ -30,14 +30,16 @@ var ( type DeploymentNotifier struct { dd DeploymentDiffer ghc *github.Client + or CodeOwnerResolver environment string manifestRevision string } -func NewDeploymentNotifier(ghc *github.Client, dd DeploymentDiffer, environment, manifestRevision string) *DeploymentNotifier { +func NewDeploymentNotifier(ghc *github.Client, dd DeploymentDiffer, or CodeOwnerResolver, environment string, manifestRevision string) *DeploymentNotifier { return &DeploymentNotifier{ dd: dd, ghc: ghc, + or: or, environment: environment, manifestRevision: manifestRevision, } @@ -52,8 +54,9 @@ type DeploymentReport struct { // Services, PullRequests are a summary of all services and pull requests included in // this deployment. For more accurate association of PRs to which services got deployed, // use ServicesPerPullRequest instead. - Services []string - PullRequests []*github.PullRequest + Services []string + PullRequests []*github.PullRequest + PullRequestsCodeOwners map[int][]string // ServicesPerPullRequest is an accurate representation of exactly which pull requests // are associated with each service, because each service might be deployed with a @@ -116,13 +119,29 @@ func (dn *DeploymentNotifier) Report(ctx context.Context) (*DeploymentReport, er return nil, ErrNoRelevantChanges } + // tmpFolder, err := os.MkdirTemp("", "sourcegraph-xxxxxx") + // defer func() { + // _ = os.RemoveAll(tmpFolder) + // }() + + prOwners := map[int][]string{} + for _, pr := range prs { + // get the owners + ref := pr.GetMergeCommitSHA() + prOwners[pr.GetNumber()], err = dn.or.Resolve(ref) + if err != nil { + return nil, err + } + } + return &DeploymentReport{ - Environment: dn.environment, - PullRequests: prs, - DeployedAt: time.Now().In(time.UTC).Format(time.RFC822Z), - Services: deployedServices, - BuildkiteBuildURL: os.Getenv("BUILDKITE_BUILD_URL"), - ManifestRevision: dn.manifestRevision, + Environment: dn.environment, + PullRequests: prs, + PullRequestsCodeOwners: prOwners, + DeployedAt: time.Now().In(time.UTC).Format(time.RFC822Z), + Services: deployedServices, + BuildkiteBuildURL: os.Getenv("BUILDKITE_BUILD_URL"), + ManifestRevision: dn.manifestRevision, ServicesPerPullRequest: makeServicesPerPullRequest(prServicesMap), }, nil diff --git a/enterprise/dev/deployment-notifier/deployment_notifier_test.go b/enterprise/dev/deployment-notifier/deployment_notifier_test.go index 0ec09667529a0..817a780e9c3f0 100644 --- a/enterprise/dev/deployment-notifier/deployment_notifier_test.go +++ b/enterprise/dev/deployment-notifier/deployment_notifier_test.go @@ -73,6 +73,7 @@ func TestDeploymentNotifier(t *testing.T) { dn := NewDeploymentNotifier( ghc, NewMockManifestDeployementsDiffer(m), + NewMockCodeOwnerResolver(nil), "tests", "", ) @@ -99,6 +100,7 @@ func TestDeploymentNotifier(t *testing.T) { dn := NewDeploymentNotifier( ghc, NewMockManifestDeployementsDiffer(m), + NewMockCodeOwnerResolver(nil), "tests", "", ) @@ -133,6 +135,7 @@ func TestDeploymentNotifier(t *testing.T) { dn := NewDeploymentNotifier( ghc, NewMockManifestDeployementsDiffer(m), + NewMockCodeOwnerResolver(nil), "tests", "", ) @@ -166,6 +169,7 @@ func TestDeploymentNotifier(t *testing.T) { dn := NewDeploymentNotifier( ghc, NewMockManifestDeployementsDiffer(m), + NewMockCodeOwnerResolver(nil), "tests", "", ) diff --git a/enterprise/dev/deployment-notifier/main.go b/enterprise/dev/deployment-notifier/main.go index 86aeabcb72876..564f7cdf2cd77 100644 --- a/enterprise/dev/deployment-notifier/main.go +++ b/enterprise/dev/deployment-notifier/main.go @@ -23,6 +23,7 @@ import ( type Flags struct { GitHubToken string DryRun bool + CodeOwners bool Environment string SlackToken string SlackAnnounceWebhook string @@ -34,6 +35,7 @@ func (f *Flags) Parse() { flag.StringVar(&f.GitHubToken, "github.token", os.Getenv("GITHUB_TOKEN"), "mandatory github token") flag.StringVar(&f.Environment, "environment", "", "Environment being deployed") flag.BoolVar(&f.DryRun, "dry", false, "Pretend to post notifications, printing to stdout instead") + flag.BoolVar(&f.CodeOwners, "codeowners", false, "Mention code owners for each PR on Slack") flag.StringVar(&f.SlackToken, "slack.token", "", "mandatory slack api token") flag.StringVar(&f.SlackAnnounceWebhook, "slack.webhook", "", "Slack Webhook URL to post the results on") flag.StringVar(&f.HoneycombToken, "honeycomb.token", "", "mandatory honeycomb api token") @@ -71,10 +73,24 @@ func main() { log.Fatal(err) } - dd := NewManifestDeploymentDiffer(changedFiles) + var or CodeOwnerResolver + if flags.CodeOwners { + tmpDir, err := os.MkdirTemp("", "sourcegraph-clone-xxxxx") + if err != nil { + log.Fatal(err) + } + defer func() { + os.RemoveAll(tmpDir) + }() + or = NewGitCodeOwnerResolver("git@github.com:sourcegraph/sourcegraph.git", tmpDir) + } else { + or = NewMockCodeOwnerResolver(nil) + } + dn := NewDeploymentNotifier( ghc, - dd, + NewManifestDeploymentDiffer(changedFiles), + or, flags.Environment, manifestRevision, ) @@ -97,13 +113,14 @@ func main() { } } - // Notifcations + // Notifications slc := slack.New(flags.SlackToken) teammates := team.NewTeammateResolver(ghc, slc) if flags.DryRun { fmt.Println("Github\n---") for _, pr := range report.PullRequests { fmt.Println("-", pr.GetNumber()) + fmt.Println(" - code owners:", strings.Join(report.PullRequestsCodeOwners[pr.GetNumber()], ", ")) } out, err := renderComment(report, traceURL) if err != nil { diff --git a/enterprise/dev/deployment-notifier/slack.go b/enterprise/dev/deployment-notifier/slack.go index ddc62f3f2e8f9..f714ffbe39bdd 100644 --- a/enterprise/dev/deployment-notifier/slack.go +++ b/enterprise/dev/deployment-notifier/slack.go @@ -24,6 +24,9 @@ var slackTemplate = `:arrow_left: *{{.Environment}}* deployment (<{{.BuildURL}}| - Pull Requests: {{- range .PullRequests }} - <{{ .WebURL }}|{{ .Name }}> {{ .AuthorSlackID }} + {{- if .Owners }} + - Owners: {{- range .Owners}}{{ . }}{{" "}}{{- end}} + {{- end }} {{- end }}` type slackSummaryPresenter struct { @@ -37,6 +40,7 @@ type pullRequestPresenter struct { Name string AuthorSlackID string WebURL string + Owners []string } func slackSummary(ctx context.Context, teammates team.TeammateResolver, report *DeploymentReport, traceURL string) (string, error) { @@ -63,10 +67,23 @@ func slackSummary(ctx context.Context, teammates team.TeammateResolver, report * } } + ghOwners := report.PullRequestsCodeOwners[pr.GetNumber()] + slackOwners := make([]string, 0, len(ghOwners)) + for _, ghOwner := range ghOwners { + teammate, err := teammates.ResolveByGitHubHandle(ctx, ghOwner) + if err != nil { + // TODO handle teams + slackOwners = append(slackOwners, "`"+ghOwner+"`") + } else { + slackOwners = append(slackOwners, fmt.Sprintf("<@%s>", teammate.SlackID)) + } + } + presenter.PullRequests = append(presenter.PullRequests, pullRequestPresenter{ Name: pr.GetTitle(), WebURL: pr.GetHTMLURL(), AuthorSlackID: authorSlackID, + Owners: slackOwners, }) } diff --git a/go.mod b/go.mod index e1acf50f689ff..be7ba161bf8f6 100644 --- a/go.mod +++ b/go.mod @@ -130,6 +130,7 @@ require ( github.com/slack-go/slack v0.10.1 github.com/smacker/go-tree-sitter v0.0.0-20220209044044-0d3022e933c3 github.com/snabb/sitemap v1.0.0 + github.com/sourcegraph/codenotify v0.5.2-0.20220415141925-41450faf3c5e github.com/sourcegraph/ctxvfs v0.0.0-20180418081416-2b65f1b1ea81 github.com/sourcegraph/go-ctags v0.0.0-20220404085534-f974026334d7 github.com/sourcegraph/go-diff v0.6.1 diff --git a/go.sum b/go.sum index 67d538c5b2f20..71f5a97376f59 100644 --- a/go.sum +++ b/go.sum @@ -2183,6 +2183,8 @@ github.com/sourcegraph/alertmanager v0.21.1-0.20211110092431-863f5b1ee51b h1:Mly github.com/sourcegraph/alertmanager v0.21.1-0.20211110092431-863f5b1ee51b/go.mod h1:0MLTrjQI8EuVmvykEhcfr/7X0xmaDAZrqMgxIq3OXHk= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/codenotify v0.5.2-0.20220415141925-41450faf3c5e h1:4ecubQEIntQAXTqiVUH21XfIXY/CZUt87s5tMb0uQH8= +github.com/sourcegraph/codenotify v0.5.2-0.20220415141925-41450faf3c5e/go.mod h1:JUDBqComAfChmUf1EULTIB5BvzM0YP5pIf3WZE3UHlE= github.com/sourcegraph/ctxvfs v0.0.0-20180418081416-2b65f1b1ea81 h1:v4/JVxZSPWifxmICRqgXK7khThjw03RfdGhyeA2S4EQ= github.com/sourcegraph/ctxvfs v0.0.0-20180418081416-2b65f1b1ea81/go.mod h1:xIvvI5FiHLxhv8prbzVpaMHaaGPFPFQSuTcxC91ryOo= github.com/sourcegraph/go-ctags v0.0.0-20220404085534-f974026334d7 h1:jl6zTZRCzF4V2ZjBBlrW2Sscd639mHKhGtMYKaBgEiQ=