diff --git a/metrics/cmd/deployments.go b/metrics/cmd/deployments.go new file mode 100644 index 0000000..0c989d9 --- /dev/null +++ b/metrics/cmd/deployments.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/factory" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/spf13/cobra" +) + +type DeploymentsOptions struct { + Limit int + Environments *[]string +} + +func newDeploymentsCmd(f *factory.Factory) *cobra.Command { + opts := new(DeploymentsOptions) + + cmd := &cobra.Command{ + Use: "deployments", + Short: "Retrieve data about GitHub Deployments", + Long: "Retrieve data about GitHub Deployments", + PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.Limit < 1 { + return fmt.Errorf("Limit cannot be smaller than 1.") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runDeployments(cmd.Root().Context(), f, opts) + }, + } + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "limit for how many Deployments to fetch") + + opts.Environments = cmd.Flags().StringArray("env", nil, "multiple use for Deployment environments") + + return cmd +} + +func runDeployments(ctx context.Context, f *factory.Factory, opts *DeploymentsOptions) error { + repo, err := f.NewGitHubRepo() + if err != nil { + return err + } + + gqlClient, err := f.NewGitHubGraphQLClient() + if err != nil { + return err + } + + deployments, err := github.QueryDeployments(gqlClient, repo, opts.Limit, opts.Environments) + if err != nil { + return err + } + + exporter, err := f.NewExporter() + if err != nil { + return err + } + + return exporter.Export(deployments) +} diff --git a/metrics/cmd/deployments_test.go b/metrics/cmd/deployments_test.go new file mode 100644 index 0000000..1c31908 --- /dev/null +++ b/metrics/cmd/deployments_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/config" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/mozilla-services/rapid-release-model/metrics/internal/test" + "github.com/shurcooL/githubv4" +) + +func TestDeployments(t *testing.T) { + repo := &github.Repo{Owner: "hackebrot", Name: "turtle"} + + env := map[string]string{ + config.EnvKey("GITHUB", "REPO_OWNER"): "", + config.EnvKey("GITHUB", "REPO_NAME"): "", + } + + tempDir := t.TempDir() + + tests := []test.TestCase{{ + Name: "deployments__repo_owner__required", + Args: []string{"github", "-n", repo.Name, "deployments"}, + ErrContains: "Repo.Owner and Repo.Name are required. Set env vars or pass flags", + Env: env, + }, { + Name: "deployments__repo_name__required", + Args: []string{"github", "-o", repo.Owner, "deployments"}, + ErrContains: "Repo.Owner and Repo.Name are required. Set env vars or pass flags", + Env: env, + }, { + Name: "deployments__default", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "deployments"}, + WantFixture: test.NewFixture("deployments", "want__default.json"), + Env: env, + }, { + Name: "deployments__limit", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "deployments", "-l", "2"}, + WantFixture: test.NewFixture("deployments", "want__limit.json"), + Env: env, + }, { + Name: "deployments__json", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "deployments", "-e", "json"}, + WantFixture: test.NewFixture("deployments", "want__default.json"), + Env: env, + }, { + Name: "deployments__csv", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "deployments", "-e", "csv"}, + WantFixture: test.NewFixture("deployments", "want__default.csv"), + Env: env, + }, { + Name: "deployments__filename", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "deployments", "-f", filepath.Join(tempDir, "r.json")}, + WantFixture: test.NewFixture("deployments", "want__default.json"), + WantFile: filepath.Join(tempDir, "r.json"), + Env: env, + }, { + Name: "deployments__env__single", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "deployments", "--env", "prod"}, + WantVariables: map[string]interface{}{"environments": []githubv4.String{githubv4.String("prod")}}, + Env: env, + }, { + Name: "deployments__env__multiple", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "deployments", "--env", "prod", "--env", "hello"}, + WantVariables: map[string]interface{}{"environments": []githubv4.String{githubv4.String("prod"), githubv4.String("hello")}}, + Env: env, + }} + + test.RunTests(t, newRootCmd, tests) +} diff --git a/metrics/cmd/fixtures/deployments/query.json b/metrics/cmd/fixtures/deployments/query.json new file mode 100644 index 0000000..1bad66b --- /dev/null +++ b/metrics/cmd/fixtures/deployments/query.json @@ -0,0 +1,52 @@ +{ + "Repository": { + "Name": "turtle", + "Owner": { + "Login": "hackebrot" + }, + "Deployments": { + "PageInfo": { + "HasNextPage": true, + "EndCursor": "abc123" + }, + "Nodes": [ + { + "Description": "Deployment03", + "CreatedAt": "2022-05-02T20:25:05Z", + "UpdatedAt": "2022-05-02T20:25:05Z", + "OriginalEnvironment": "prod", + "LatestEnvironment": "prod", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "1abc111" + } + }, + { + "Description": "Deployment03", + "CreatedAt": "2022-05-01T20:20:05Z", + "UpdatedAt": "2022-05-01T20:20:05Z", + "OriginalEnvironment": "stage", + "LatestEnvironment": "stage", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "1abc111" + } + }, + { + "Description": "Deployment02", + "CreatedAt": "2022-04-01T20:25:05Z", + "UpdatedAt": "2022-04-01T20:25:05Z", + "OriginalEnvironment": "stage", + "LatestEnvironment": "stage", + "Task": "deploy", + "State": "INACTIVE", + "Commit": { + "AbbreviatedOid": "2abc111" + } + } + ] + } + } +} \ No newline at end of file diff --git a/metrics/cmd/fixtures/deployments/query_abc123.json b/metrics/cmd/fixtures/deployments/query_abc123.json new file mode 100644 index 0000000..140ea01 --- /dev/null +++ b/metrics/cmd/fixtures/deployments/query_abc123.json @@ -0,0 +1,28 @@ +{ + "Repository": { + "Name": "turtle", + "Owner": { + "Login": "hackebrot" + }, + "Deployments": { + "PageInfo": { + "HasNextPage": false, + "EndCursor": "abc456" + }, + "Nodes": [ + { + "Description": "Deployment01", + "CreatedAt": "2022-02-01T20:25:05Z", + "UpdatedAt": "2022-02-01T20:25:05Z", + "OriginalEnvironment": "hello", + "LatestEnvironment": "hello", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "3abc111" + } + } + ] + } + } +} \ No newline at end of file diff --git a/metrics/cmd/fixtures/deployments/want__default.csv b/metrics/cmd/fixtures/deployments/want__default.csv new file mode 100644 index 0000000..599aacc --- /dev/null +++ b/metrics/cmd/fixtures/deployments/want__default.csv @@ -0,0 +1,5 @@ +description,createdAt,updatedAt,originalEnvironment,latestEnvironment,task,state,commitOid +Deployment03,2022-05-02T20:25:05Z,2022-05-02T20:25:05Z,prod,prod,deploy,ACTIVE,1abc111 +Deployment03,2022-05-01T20:20:05Z,2022-05-01T20:20:05Z,stage,stage,deploy,ACTIVE,1abc111 +Deployment02,2022-04-01T20:25:05Z,2022-04-01T20:25:05Z,stage,stage,deploy,INACTIVE,2abc111 +Deployment01,2022-02-01T20:25:05Z,2022-02-01T20:25:05Z,hello,hello,deploy,ACTIVE,3abc111 \ No newline at end of file diff --git a/metrics/cmd/fixtures/deployments/want__default.json b/metrics/cmd/fixtures/deployments/want__default.json new file mode 100644 index 0000000..9db8f8d --- /dev/null +++ b/metrics/cmd/fixtures/deployments/want__default.json @@ -0,0 +1,50 @@ +[ + { + "Description": "Deployment03", + "CreatedAt": "2022-05-02T20:25:05Z", + "UpdatedAt": "2022-05-02T20:25:05Z", + "OriginalEnvironment": "prod", + "LatestEnvironment": "prod", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "1abc111" + } + }, + { + "Description": "Deployment03", + "CreatedAt": "2022-05-01T20:20:05Z", + "UpdatedAt": "2022-05-01T20:20:05Z", + "OriginalEnvironment": "stage", + "LatestEnvironment": "stage", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "1abc111" + } + }, + { + "Description": "Deployment02", + "CreatedAt": "2022-04-01T20:25:05Z", + "UpdatedAt": "2022-04-01T20:25:05Z", + "OriginalEnvironment": "stage", + "LatestEnvironment": "stage", + "Task": "deploy", + "State": "INACTIVE", + "Commit": { + "AbbreviatedOid": "2abc111" + } + }, + { + "Description": "Deployment01", + "CreatedAt": "2022-02-01T20:25:05Z", + "UpdatedAt": "2022-02-01T20:25:05Z", + "OriginalEnvironment": "hello", + "LatestEnvironment": "hello", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "3abc111" + } + } +] \ No newline at end of file diff --git a/metrics/cmd/fixtures/deployments/want__limit.json b/metrics/cmd/fixtures/deployments/want__limit.json new file mode 100644 index 0000000..584d741 --- /dev/null +++ b/metrics/cmd/fixtures/deployments/want__limit.json @@ -0,0 +1,26 @@ +[ + { + "Description": "Deployment03", + "CreatedAt": "2022-05-02T20:25:05Z", + "UpdatedAt": "2022-05-02T20:25:05Z", + "OriginalEnvironment": "prod", + "LatestEnvironment": "prod", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "1abc111" + } + }, + { + "Description": "Deployment03", + "CreatedAt": "2022-05-01T20:20:05Z", + "UpdatedAt": "2022-05-01T20:20:05Z", + "OriginalEnvironment": "stage", + "LatestEnvironment": "stage", + "Task": "deploy", + "State": "ACTIVE", + "Commit": { + "AbbreviatedOid": "1abc111" + } + } +] \ No newline at end of file diff --git a/metrics/cmd/fixtures/prs/query.json b/metrics/cmd/fixtures/prs/query.json new file mode 100644 index 0000000..fb47db4 --- /dev/null +++ b/metrics/cmd/fixtures/prs/query.json @@ -0,0 +1,43 @@ +{ + "Repository": { + "Name": "turtle", + "Owner": { + "Login": "hackebrot" + }, + "PullRequests": { + "PageInfo": { + "HasNextPage": true, + "EndCursor": "abc" + }, + "Nodes": [ + { + "ID": "PULLREQUEST1", + "Number": 1, + "Title": "Set up CI/CD workflow 📦", + "CreatedAt": "2023-09-08T16:33:20Z", + "UpdatedAt": "2023-09-10T07:24:20Z", + "ClosedAt": "2023-09-10T07:24:17Z", + "MergedAt": "2023-09-10T07:24:16Z" + }, + { + "ID": "PULLREQUEST2", + "Number": 2, + "Title": "Refactor test framework 🤖", + "CreatedAt": "2023-09-08T09:18:42Z", + "UpdatedAt": "2023-09-08T09:40:23Z", + "ClosedAt": "2023-09-08T09:40:20Z", + "MergedAt": "2023-09-08T09:40:19Z" + }, + { + "ID": "PULLREQUEST3", + "Number": 3, + "Title": "Fetch deployment metrics 🚀", + "CreatedAt": "2023-10-08T08:09:38Z", + "UpdatedAt": "2023-10-08T08:58:13Z", + "ClosedAt": "2023-11-08T08:58:10Z", + "MergedAt": "2023-11-08T08:58:10Z" + } + ] + } + } +} \ No newline at end of file diff --git a/metrics/cmd/fixtures/prs/query_abc.json b/metrics/cmd/fixtures/prs/query_abc.json new file mode 100644 index 0000000..189e274 --- /dev/null +++ b/metrics/cmd/fixtures/prs/query_abc.json @@ -0,0 +1,25 @@ +{ + "Repository": { + "Name": "turtle", + "Owner": { + "Login": "hackebrot" + }, + "PullRequests": { + "PageInfo": { + "HasNextPage": false, + "EndCursor": "hello" + }, + "Nodes": [ + { + "ID": "PULLREQUEST4", + "Number": 4, + "Title": "Updating Docker image 📦", + "CreatedAt": "2023-12-08T16:33:20Z", + "UpdatedAt": "2023-12-10T07:24:20Z", + "ClosedAt": "2023-12-10T07:24:17Z", + "MergedAt": "2023-12-10T07:24:16Z" + } + ] + } + } +} \ No newline at end of file diff --git a/metrics/cmd/fixtures/prs/want__default.csv b/metrics/cmd/fixtures/prs/want__default.csv new file mode 100644 index 0000000..c39625d --- /dev/null +++ b/metrics/cmd/fixtures/prs/want__default.csv @@ -0,0 +1,5 @@ +id,number,title,createdAt,updatedAt,closedAt,mergedAt +PULLREQUEST1,1,Set up CI/CD workflow 📦,2023-09-08T16:33:20Z,2023-09-10T07:24:20Z,2023-09-10T07:24:17Z,2023-09-10T07:24:16Z +PULLREQUEST2,2,Refactor test framework 🤖,2023-09-08T09:18:42Z,2023-09-08T09:40:23Z,2023-09-08T09:40:20Z,2023-09-08T09:40:19Z +PULLREQUEST3,3,Fetch deployment metrics 🚀,2023-10-08T08:09:38Z,2023-10-08T08:58:13Z,2023-11-08T08:58:10Z,2023-11-08T08:58:10Z +PULLREQUEST4,4,Updating Docker image 📦,2023-12-08T16:33:20Z,2023-12-10T07:24:20Z,2023-12-10T07:24:17Z,2023-12-10T07:24:16Z \ No newline at end of file diff --git a/metrics/cmd/fixtures/prs/want__default.json b/metrics/cmd/fixtures/prs/want__default.json new file mode 100644 index 0000000..49d6310 --- /dev/null +++ b/metrics/cmd/fixtures/prs/want__default.json @@ -0,0 +1,38 @@ +[ + { + "ID": "PULLREQUEST1", + "Number": 1, + "Title": "Set up CI/CD workflow 📦", + "CreatedAt": "2023-09-08T16:33:20Z", + "UpdatedAt": "2023-09-10T07:24:20Z", + "ClosedAt": "2023-09-10T07:24:17Z", + "MergedAt": "2023-09-10T07:24:16Z" + }, + { + "ID": "PULLREQUEST2", + "Number": 2, + "Title": "Refactor test framework 🤖", + "CreatedAt": "2023-09-08T09:18:42Z", + "UpdatedAt": "2023-09-08T09:40:23Z", + "ClosedAt": "2023-09-08T09:40:20Z", + "MergedAt": "2023-09-08T09:40:19Z" + }, + { + "ID": "PULLREQUEST3", + "Number": 3, + "Title": "Fetch deployment metrics 🚀", + "CreatedAt": "2023-10-08T08:09:38Z", + "UpdatedAt": "2023-10-08T08:58:13Z", + "ClosedAt": "2023-11-08T08:58:10Z", + "MergedAt": "2023-11-08T08:58:10Z" + }, + { + "ID": "PULLREQUEST4", + "Number": 4, + "Title": "Updating Docker image 📦", + "CreatedAt": "2023-12-08T16:33:20Z", + "UpdatedAt": "2023-12-10T07:24:20Z", + "ClosedAt": "2023-12-10T07:24:17Z", + "MergedAt": "2023-12-10T07:24:16Z" + } +] \ No newline at end of file diff --git a/metrics/cmd/fixtures/prs/want__limit.json b/metrics/cmd/fixtures/prs/want__limit.json new file mode 100644 index 0000000..ba65394 --- /dev/null +++ b/metrics/cmd/fixtures/prs/want__limit.json @@ -0,0 +1,20 @@ +[ + { + "ID": "PULLREQUEST1", + "Number": 1, + "Title": "Set up CI/CD workflow 📦", + "CreatedAt": "2023-09-08T16:33:20Z", + "UpdatedAt": "2023-09-10T07:24:20Z", + "ClosedAt": "2023-09-10T07:24:17Z", + "MergedAt": "2023-09-10T07:24:16Z" + }, + { + "ID": "PULLREQUEST2", + "Number": 2, + "Title": "Refactor test framework 🤖", + "CreatedAt": "2023-09-08T09:18:42Z", + "UpdatedAt": "2023-09-08T09:40:23Z", + "ClosedAt": "2023-09-08T09:40:20Z", + "MergedAt": "2023-09-08T09:40:19Z" + } +] \ No newline at end of file diff --git a/metrics/cmd/fixtures/releases/query.json b/metrics/cmd/fixtures/releases/query.json new file mode 100644 index 0000000..032c60c --- /dev/null +++ b/metrics/cmd/fixtures/releases/query.json @@ -0,0 +1,36 @@ +{ + "Repository": { + "Name": "turtle", + "Owner": { + "Login": "hackebrot" + }, + "Releases": { + "PageInfo": { + "HasNextPage": true, + "EndCursor": "123" + }, + "Nodes": [ + { + "Name": "20.1.0", + "TagName": "20.1.0", + "IsDraft": false, + "IsLatest": true, + "IsPrerelease": false, + "Description": "Description for 20.1.0", + "CreatedAt": "2020-05-04T14:55:36Z", + "PublishedAt": "2020-05-04T15:02:21Z" + }, + { + "Name": "0.2.0", + "TagName": "0.2.0", + "IsDraft": false, + "IsLatest": false, + "IsPrerelease": false, + "Description": "Description for 0.2.0", + "CreatedAt": "2019-12-15T17:35:58Z", + "PublishedAt": "2019-12-15T20:00:44Z" + } + ] + } + } +} \ No newline at end of file diff --git a/metrics/cmd/fixtures/releases/query_123.json b/metrics/cmd/fixtures/releases/query_123.json new file mode 100644 index 0000000..c7dd439 --- /dev/null +++ b/metrics/cmd/fixtures/releases/query_123.json @@ -0,0 +1,26 @@ +{ + "Repository": { + "Name": "turtle", + "Owner": { + "Login": "hackebrot" + }, + "Releases": { + "PageInfo": { + "HasNextPage": false, + "EndCursor": "world" + }, + "Nodes": [ + { + "Name": "0.1.0", + "TagName": "0.1.0", + "IsDraft": false, + "IsLatest": false, + "IsPrerelease": false, + "Description": "Description for 0.1.0", + "CreatedAt": "2018-07-13T15:23:49Z", + "PublishedAt": "2018-07-16T13:30:36Z" + } + ] + } + } +} \ No newline at end of file diff --git a/metrics/cmd/fixtures/releases/want__default.csv b/metrics/cmd/fixtures/releases/want__default.csv new file mode 100644 index 0000000..dfe3ad9 --- /dev/null +++ b/metrics/cmd/fixtures/releases/want__default.csv @@ -0,0 +1,4 @@ +name,tagName,isDraft,isLatest,isPrerelease,description,createdAt,publishedAt +20.1.0,20.1.0,false,true,false,Description for 20.1.0,2020-05-04T14:55:36Z,2020-05-04T15:02:21Z +0.2.0,0.2.0,false,false,false,Description for 0.2.0,2019-12-15T17:35:58Z,2019-12-15T20:00:44Z +0.1.0,0.1.0,false,false,false,Description for 0.1.0,2018-07-13T15:23:49Z,2018-07-16T13:30:36Z \ No newline at end of file diff --git a/metrics/cmd/fixtures/releases/want__default.json b/metrics/cmd/fixtures/releases/want__default.json new file mode 100644 index 0000000..2232efe --- /dev/null +++ b/metrics/cmd/fixtures/releases/want__default.json @@ -0,0 +1,32 @@ +[ + { + "Name": "20.1.0", + "TagName": "20.1.0", + "IsDraft": false, + "IsLatest": true, + "IsPrerelease": false, + "Description": "Description for 20.1.0", + "CreatedAt": "2020-05-04T14:55:36Z", + "PublishedAt": "2020-05-04T15:02:21Z" + }, + { + "Name": "0.2.0", + "TagName": "0.2.0", + "IsDraft": false, + "IsLatest": false, + "IsPrerelease": false, + "Description": "Description for 0.2.0", + "CreatedAt": "2019-12-15T17:35:58Z", + "PublishedAt": "2019-12-15T20:00:44Z" + }, + { + "Name": "0.1.0", + "TagName": "0.1.0", + "IsDraft": false, + "IsLatest": false, + "IsPrerelease": false, + "Description": "Description for 0.1.0", + "CreatedAt": "2018-07-13T15:23:49Z", + "PublishedAt": "2018-07-16T13:30:36Z" + } +] \ No newline at end of file diff --git a/metrics/cmd/fixtures/releases/want__limit.json b/metrics/cmd/fixtures/releases/want__limit.json new file mode 100644 index 0000000..60dfd8d --- /dev/null +++ b/metrics/cmd/fixtures/releases/want__limit.json @@ -0,0 +1,12 @@ +[ + { + "Name": "20.1.0", + "TagName": "20.1.0", + "IsDraft": false, + "IsLatest": true, + "IsPrerelease": false, + "Description": "Description for 20.1.0", + "CreatedAt": "2020-05-04T14:55:36Z", + "PublishedAt": "2020-05-04T15:02:21Z" + } +] \ No newline at end of file diff --git a/metrics/cmd/github.go b/metrics/cmd/github.go new file mode 100644 index 0000000..4acf362 --- /dev/null +++ b/metrics/cmd/github.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/factory" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/spf13/cobra" +) + +func newGitHubCmd(f *factory.Factory) *cobra.Command { + repo, err := f.NewGitHubRepo() + if err != nil { + panic(err) + } + + cmd := &cobra.Command{ + Use: "github", + Short: "Retrieve metrics from GitHub", + Long: "Retrieve metrics from GitHub", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if repo.Owner == "" || repo.Name == "" { + return fmt.Errorf("Repo.Owner and Repo.Name are required. Set env vars or pass flags.") + } + f.NewGitHubRepo = func() (*github.Repo, error) { + return repo, nil + } + return nil + }, + } + + cmd.PersistentFlags().StringVarP(&repo.Owner, "repo-owner", "o", repo.Owner, "owner of the GitHub repo") + cmd.PersistentFlags().StringVarP(&repo.Name, "repo-name", "n", repo.Name, "name of the GitHub repo") + + cmd.AddCommand(newPullRequestsCmd(f)) + cmd.AddCommand(newReleasesCmd(f)) + cmd.AddCommand(newDeploymentsCmd(f)) + + return cmd +} diff --git a/metrics/cmd/github_test.go b/metrics/cmd/github_test.go new file mode 100644 index 0000000..51f0e92 --- /dev/null +++ b/metrics/cmd/github_test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "testing" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/config" + "github.com/mozilla-services/rapid-release-model/metrics/internal/test" +) + +func TestGitHub(t *testing.T) { + env := map[string]string{ + config.EnvKey("GITHUB", "REPO_OWNER"): "", + config.EnvKey("GITHUB", "REPO_NAME"): "", + } + + tests := []test.TestCase{ + { + Name: "github", + Args: []string{"github"}, + ErrContains: "", + Env: env, + }, + } + + test.RunTests(t, newRootCmd, tests) +} diff --git a/metrics/cmd/pull_requests.go b/metrics/cmd/pull_requests.go new file mode 100644 index 0000000..3149167 --- /dev/null +++ b/metrics/cmd/pull_requests.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/factory" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/spf13/cobra" +) + +type PullRequestsOptions struct { + Limit int +} + +func newPullRequestsCmd(f *factory.Factory) *cobra.Command { + opts := new(PullRequestsOptions) + + cmd := &cobra.Command{ + Use: "prs", + Short: "Retrieve data about GitHub Pull Requests", + Long: "Retrieve data about GitHub Pull Requests", + PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.Limit < 1 { + return fmt.Errorf("Limit cannot be smaller than 1.") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runPullRequests(cmd.Root().Context(), f, opts) + }, + } + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "limit for how many PRs to fetch") + return cmd +} + +func runPullRequests(ctx context.Context, f *factory.Factory, opts *PullRequestsOptions) error { + repo, err := f.NewGitHubRepo() + if err != nil { + return err + } + + gqlClient, err := f.NewGitHubGraphQLClient() + if err != nil { + return err + } + + pullRequests, err := github.QueryPullRequests(gqlClient, repo, opts.Limit) + if err != nil { + return err + } + + exporter, err := f.NewExporter() + if err != nil { + return err + } + + return exporter.Export(pullRequests) +} diff --git a/metrics/cmd/pull_requests_test.go b/metrics/cmd/pull_requests_test.go new file mode 100644 index 0000000..b614383 --- /dev/null +++ b/metrics/cmd/pull_requests_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/config" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/mozilla-services/rapid-release-model/metrics/internal/test" +) + +func TestPullRequests(t *testing.T) { + repo := &github.Repo{Owner: "hackebrot", Name: "turtle"} + + env := map[string]string{ + config.EnvKey("GITHUB", "REPO_OWNER"): "", + config.EnvKey("GITHUB", "REPO_NAME"): "", + } + + tempDir := t.TempDir() + + tests := []test.TestCase{{ + Name: "prs__repo_owner__required", + Args: []string{"github", "-n", repo.Name, "prs"}, + ErrContains: "Repo.Owner and Repo.Name are required. Set env vars or pass flags", + Env: env, + }, { + Name: "prs__repo_name__required", + Args: []string{"github", "-o", repo.Owner, "prs"}, + ErrContains: "Repo.Owner and Repo.Name are required. Set env vars or pass flags", + Env: env, + }, { + Name: "prs__default", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "prs"}, + WantFixture: test.NewFixture("prs", "want__default.json"), + Env: env, + }, { + Name: "prs__limit", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "prs", "-l", "2"}, + WantFixture: test.NewFixture("prs", "want__limit.json"), + Env: env, + }, { + Name: "prs__json", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "prs", "-e", "json"}, + WantFixture: test.NewFixture("prs", "want__default.json"), + Env: env, + }, { + Name: "prs__csv", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "prs", "-e", "csv"}, + WantFixture: test.NewFixture("prs", "want__default.csv"), + Env: env, + }, { + Name: "prs__filename", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "prs", "-f", filepath.Join(tempDir, "prs.json")}, + WantFixture: test.NewFixture("prs", "want__default.json"), + WantFile: filepath.Join(tempDir, "prs.json"), + Env: env, + }} + + test.RunTests(t, newRootCmd, tests) +} diff --git a/metrics/cmd/releases.go b/metrics/cmd/releases.go new file mode 100644 index 0000000..c8f301a --- /dev/null +++ b/metrics/cmd/releases.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/factory" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/spf13/cobra" +) + +type ReleasesOptions struct { + Limit int +} + +func newReleasesCmd(f *factory.Factory) *cobra.Command { + opts := new(ReleasesOptions) + + cmd := &cobra.Command{ + Use: "releases", + Short: "Retrieve data about GitHub Releases", + Long: "Retrieve data about GitHub Releases", + PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.Limit < 1 { + return fmt.Errorf("Limit cannot be smaller than 1.") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runReleases(cmd.Root().Context(), f, opts) + }, + } + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "limit for how many Releases to fetch") + return cmd +} + +func runReleases(ctx context.Context, f *factory.Factory, opts *ReleasesOptions) error { + repo, err := f.NewGitHubRepo() + if err != nil { + return err + } + + gqlClient, err := f.NewGitHubGraphQLClient() + if err != nil { + return err + } + + releases, err := github.QueryReleases(gqlClient, repo, opts.Limit) + if err != nil { + return err + } + + exporter, err := f.NewExporter() + if err != nil { + return err + } + + return exporter.Export(releases) +} diff --git a/metrics/cmd/releases_test.go b/metrics/cmd/releases_test.go new file mode 100644 index 0000000..99e0f3f --- /dev/null +++ b/metrics/cmd/releases_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/config" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/mozilla-services/rapid-release-model/metrics/internal/test" +) + +func TestReleases(t *testing.T) { + repo := &github.Repo{Owner: "hackebrot", Name: "turtle"} + + env := map[string]string{ + config.EnvKey("GITHUB", "REPO_OWNER"): "", + config.EnvKey("GITHUB", "REPO_NAME"): "", + } + + tempDir := t.TempDir() + + tests := []test.TestCase{{ + Name: "releases__repo_owner__required", + Args: []string{"github", "-n", repo.Name, "releases"}, + ErrContains: "Repo.Owner and Repo.Name are required. Set env vars or pass flags", + Env: env, + }, { + Name: "releases__repo_name__required", + Args: []string{"github", "-o", repo.Owner, "releases"}, + ErrContains: "Repo.Owner and Repo.Name are required. Set env vars or pass flags", + Env: env, + }, { + Name: "releases__default", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "releases"}, + WantFixture: test.NewFixture("releases", "want__default.json"), + Env: env, + }, { + Name: "releases__limit", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "releases", "-l", "1"}, + WantFixture: test.NewFixture("releases", "want__limit.json"), + Env: env, + }, { + Name: "releases__json", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "releases", "-e", "json"}, + WantFixture: test.NewFixture("releases", "want__default.json"), + Env: env, + }, { + Name: "releases__csv", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "releases", "-e", "csv"}, + WantFixture: test.NewFixture("releases", "want__default.csv"), + Env: env, + }, { + Name: "releases__filename", + Args: []string{"github", "-o", repo.Owner, "-n", repo.Name, "releases", "-f", filepath.Join(tempDir, "r.json")}, + WantFixture: test.NewFixture("releases", "want__default.json"), + WantFile: filepath.Join(tempDir, "r.json"), + Env: env, + }} + + test.RunTests(t, newRootCmd, tests) +} diff --git a/metrics/cmd/root.go b/metrics/cmd/root.go index f0a58fd..f20d2c2 100644 --- a/metrics/cmd/root.go +++ b/metrics/cmd/root.go @@ -1,75 +1,84 @@ package cmd import ( + "context" "fmt" - "io" "os" + "github.com/mozilla-services/rapid-release-model/metrics/internal/export" + "github.com/mozilla-services/rapid-release-model/metrics/internal/factory" "github.com/spf13/cobra" ) -// Prefix for application specific environment variables -const envPrefix = "RRM_METRICS_" - -// Repo for which to retrieve metrics -type Repo struct { - Owner string - Name string -} - -// Options for the cmd -type Options struct { - Out io.Writer - Repo *Repo +type MetricsOptions struct { + Export struct { + Encoding string + Filename string + } } // newRootCmd creates a new base command for the metrics CLI app -func newRootCmd(w io.Writer) *cobra.Command { - opts := &Options{ - Out: w, - Repo: &Repo{ - Owner: os.Getenv(envPrefix + "REPO_OWNER"), - Name: os.Getenv(envPrefix + "REPO_NAME"), - }, - } +func newRootCmd(f *factory.Factory) *cobra.Command { + opts := new(MetricsOptions) rootCmd := &cobra.Command{ Use: "metrics", Short: "Retrieve software delivery performance metrics", Long: "Retrieve software delivery performance metrics", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if opts.Repo.Owner == "" { - return fmt.Errorf("Repo.Owner is required. Set env var or pass flag.") + switch opts.Export.Encoding { + case "json": + f.NewEncoder = func() (export.Encoder, error) { + return export.NewJSONEncoder() + } + case "csv": + f.NewEncoder = func() (export.Encoder, error) { + return export.NewCSVEncoder() + } + case "plain": + f.NewEncoder = func() (export.Encoder, error) { + return export.NewPlainEncoder() + } + default: + return fmt.Errorf("unsupported Export.Encoding. Please use 'json', 'csv', or 'plain'.") } - if opts.Repo.Name == "" { - return fmt.Errorf("Repo.Name is required. Set env var or pass flag.") + + if opts.Export.Filename != "" { + f.NewExporter = func() (export.Exporter, error) { + encoder, err := f.NewEncoder() + if err != nil { + return nil, err + } + return export.NewFileExporter(encoder, opts.Export.Filename) + } } + return nil }, - RunE: func(cmd *cobra.Command, args []string) error { - return runRoot(opts) - }, } - rootCmd.PersistentFlags().StringVarP(&opts.Repo.Owner, "repo-owner", "o", opts.Repo.Owner, "owner of the GitHub repo") - rootCmd.PersistentFlags().StringVarP(&opts.Repo.Name, "repo-name", "n", opts.Repo.Name, "name of the GitHub repo") + rootCmd.PersistentFlags().StringVarP(&opts.Export.Encoding, "encoding", "e", "json", "export encoding") + rootCmd.PersistentFlags().StringVarP(&opts.Export.Filename, "filename", "f", "", "export to file") - return rootCmd -} + rootCmd.AddCommand(newGitHubCmd(f)) -// runRoot performs the action for the metrics CLI command -func runRoot(opts *Options) error { - if _, err := fmt.Fprintf(opts.Out, "Retrieving metrics for %s/%s\n", opts.Repo.Owner, opts.Repo.Name); err != nil { - return err - } - return nil + return rootCmd } // Execute the CLI application and write errors to os.Stderr func Execute() { - rootCmd := newRootCmd(os.Stdout) - if err := rootCmd.Execute(); err != nil { + ctx := context.Background() + factory := factory.NewFactory(ctx) + rootCmd := newRootCmd(factory) + if err := rootCmd.ExecuteContext(ctx); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } + +func init() { + // New in cobra v1.8.0. See https://github.com/spf13/cobra/pull/2044 + // Run all PersistentPreRunE hooks, so we don't have to repeat factory + // configuration or CLI flags parsing in sub commands. + cobra.EnableTraverseRunHooks = true +} diff --git a/metrics/cmd/root_test.go b/metrics/cmd/root_test.go index 7ee22ef..7a762f1 100644 --- a/metrics/cmd/root_test.go +++ b/metrics/cmd/root_test.go @@ -1,123 +1,26 @@ package cmd import ( - "bytes" - "fmt" - "strings" "testing" - "github.com/google/go-cmp/cmp" + "github.com/mozilla-services/rapid-release-model/metrics/internal/config" + "github.com/mozilla-services/rapid-release-model/metrics/internal/test" ) -type testCase struct { - // Name of the test case - name string - - // Environment variables to be set for the test case - env map[string]string - - // Arguments to be passed to the CLI app - args []string - - // Expected output from the CLI app - output string - - // Text expected in error. Empty string means no error expected. - errContains string -} - func TestRoot(t *testing.T) { - repo := &Repo{Owner: "hackebrot", Name: "turtle"} - - t.Setenv("RRM_METRICS_REPO_OWNER", "") - t.Setenv("RRM_METRICS_REPO_NAME", "") - - tests := []testCase{{ - name: "repo__short", - args: []string{"-o", repo.Owner, "-n", repo.Name}, - output: fmt.Sprintf("Retrieving metrics for %v/%v", repo.Owner, repo.Name), - errContains: "", - }, { - name: "repo__long", - args: []string{"--repo-owner", repo.Owner, "--repo-name", repo.Name}, - output: fmt.Sprintf("Retrieving metrics for %v/%v", repo.Owner, repo.Name), - errContains: "", - }, { - name: "read_env__name", - env: map[string]string{"RRM_METRICS_REPO_NAME": "hello"}, - args: []string{"--repo-owner", repo.Owner}, - output: fmt.Sprintf("Retrieving metrics for %v/%v", repo.Owner, "hello"), - errContains: "", - }, { - name: "read_env__owner", - env: map[string]string{"RRM_METRICS_REPO_OWNER": "mozilla"}, - args: []string{"--repo-name", repo.Name}, - output: fmt.Sprintf("Retrieving metrics for %v/%v", "mozilla", repo.Name), - errContains: "", - }, { - name: "missing_value__owner", - args: []string{"--repo-name", repo.Name}, - errContains: "Repo.Owner is required", - }, { - name: "missing_value__name", - args: []string{"--repo-owner", repo.Owner}, - errContains: "Repo.Name is required", - }} - - runTests(t, tests) -} - -// executeCmd creates and executes the metrics rood cmd -func executeCmd(args []string) (string, error) { - buf := new(bytes.Buffer) - - root := newRootCmd(buf) - root.SetOut(buf) - root.SetErr(buf) - root.SetArgs(args) - - err := root.Execute() - - return buf.String(), err -} - -// runTests is a helper for table-driven tests using subtests -func runTests(t *testing.T, tests []testCase) { - t.Helper() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Logf("running: metrics %s", strings.Join(tt.args, " ")) - - if tt.env != nil { - t.Logf("using environment: %s", tt.env) - for k, v := range tt.env { - t.Setenv(k, v) - } - } - - got, err := executeCmd(tt.args) - - if tt.errContains != "" && err == nil { - t.Fatalf("cmd did not return an error. output: %v", got) - } - - if tt.errContains == "" && err != nil { - t.Fatalf("unexpected error: %s", err) - } - - if tt.errContains != "" && err != nil && !strings.Contains(err.Error(), tt.errContains) { - t.Fatalf("error did not contain message\ngot: %v\nmissing: %v", err, tt.errContains) - } - - if tt.output != "" { - tGot := strings.TrimSpace(got) - tWant := strings.TrimSpace(tt.output) + env := map[string]string{ + config.EnvKey("GITHUB", "REPO_OWNER"): "", + config.EnvKey("GITHUB", "REPO_NAME"): "", + } - if !cmp.Equal(tGot, tWant) { - t.Fatalf("cmd returned unexpected output\ngot: %v\nwant: %v", tGot, tWant) - } - } - }) + tests := []test.TestCase{ + { + Name: "metrics", + Args: []string{}, + ErrContains: "", + Env: env, + }, } + + test.RunTests(t, newRootCmd, tests) } diff --git a/metrics/go.mod b/metrics/go.mod index c8b5d78..6cbe21d 100644 --- a/metrics/go.mod +++ b/metrics/go.mod @@ -4,10 +4,17 @@ go 1.21.3 require ( github.com/google/go-cmp v0.6.0 - github.com/spf13/cobra v1.7.0 + github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278 + github.com/spf13/cobra v1.8.0 + golang.org/x/oauth2 v0.13.0 ) require ( + github.com/golang/protobuf v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/net v0.16.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect ) diff --git a/metrics/go.sum b/metrics/go.sum index 40b90be..b69f969 100644 --- a/metrics/go.sum +++ b/metrics/go.sum @@ -1,12 +1,38 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278 h1:kdEGVAV4sO46DPtb8k793jiecUEhaX9ixoIBt41HEGU= +github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/metrics/internal/config/config.go b/metrics/internal/config/config.go new file mode 100644 index 0000000..5cf6f0e --- /dev/null +++ b/metrics/internal/config/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +// Prefix for application specific environment variables +const envPrefix string = "RRM_METRICS" + +// EnvKey returns the full key for the given key parts. +func EnvKey(p ...string) string { + parts := append([]string{envPrefix}, p...) + return strings.Join(parts, "__") +} + +// ReadFromEnv reads the environment variable for the given parts. +func ReadFromEnv(p ...string) string { + key := EnvKey(p...) + return os.Getenv(key) +} + +// ReadFromEnvE reads the environment variable for the given parts and returns +// an error if the environment variable is not set or if the value is empty. +func ReadFromEnvE(p ...string) (string, error) { + key := EnvKey(p...) + val := os.Getenv(key) + if val == "" { + return "", fmt.Errorf("Required environment variable %v not set.", key) + } + return val, nil +} diff --git a/metrics/internal/export/encoder.go b/metrics/internal/export/encoder.go new file mode 100644 index 0000000..0dabdb1 --- /dev/null +++ b/metrics/internal/export/encoder.go @@ -0,0 +1,159 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" +) + +// Interface for CSV, JSON and other encoders +type Encoder interface { + Encode(w io.Writer, v interface{}) error +} + +type JSONEcoder struct{} + +func (j *JSONEcoder) Encode(w io.Writer, v interface{}) error { + e := json.NewEncoder(w) + e.SetIndent("", " ") + return e.Encode(v) +} + +func NewJSONEncoder() (*JSONEcoder, error) { + return &JSONEcoder{}, nil +} + +type PlainEncoder struct{} + +func (p *PlainEncoder) Encode(w io.Writer, v interface{}) error { + _, err := fmt.Fprint(w, v) + return err +} + +func NewPlainEncoder() (*PlainEncoder, error) { + return &PlainEncoder{}, nil +} + +type CSVEncoder struct{} + +func (c *CSVEncoder) Encode(w io.Writer, v interface{}) error { + var records [][]string + + csvw := csv.NewWriter(w) + + switch v := v.(type) { + case []github.PullRequest: + records = PullRequestsToCSVRecords(v) + case []github.Release: + records = ReleasesToCSVRecords(v) + case []github.Deployment: + records = DeploymentsToCSVRecords(v) + default: + return fmt.Errorf("unable to export type %T to CSV", v) + } + + return csvw.WriteAll(records) +} + +func NewCSVEncoder() (*CSVEncoder, error) { + return &CSVEncoder{}, nil +} + +func PullRequestsToCSVRecords(prs []github.PullRequest) [][]string { + var records [][]string + + // Add column headers to records + records = append(records, []string{ + "id", + "number", + "title", + "createdAt", + "updatedAt", + "closedAt", + "mergedAt", + }) + + // Add a record for each pull request + for _, pr := range prs { + record := []string{ + pr.ID, + strconv.Itoa(pr.Number), + pr.Title, + pr.CreatedAt.Format(time.RFC3339), + pr.UpdatedAt.Format(time.RFC3339), + pr.ClosedAt.Format(time.RFC3339), + pr.MergedAt.Format(time.RFC3339), + } + records = append(records, record) + } + return records +} + +func ReleasesToCSVRecords(rs []github.Release) [][]string { + var records [][]string + + // Add column headers to records + records = append(records, []string{ + "name", + "tagName", + "isDraft", + "isLatest", + "isPrerelease", + "description", + "createdAt", + "publishedAt", + }) + + // Add a record for each release + for _, r := range rs { + record := []string{ + r.Name, + r.TagName, + strconv.FormatBool(r.IsDraft), + strconv.FormatBool(r.IsLatest), + strconv.FormatBool(r.IsPrerelease), + r.Description, + r.CreatedAt.Format(time.RFC3339), + r.PublishedAt.Format(time.RFC3339), + } + records = append(records, record) + } + return records +} + +func DeploymentsToCSVRecords(ds []github.Deployment) [][]string { + var records [][]string + + // Add column headers to records + records = append(records, []string{ + "description", + "createdAt", + "updatedAt", + "originalEnvironment", + "latestEnvironment", + "task", + "state", + "commitOid", + }) + + // Add a record for each deployment + for _, d := range ds { + record := []string{ + d.Description, + d.CreatedAt.Format(time.RFC3339), + d.UpdatedAt.Format(time.RFC3339), + d.OriginalEnvironment, + d.LatestEnvironment, + d.Task, + d.State, + d.Commit.AbbreviatedOid, + } + records = append(records, record) + } + return records +} diff --git a/metrics/internal/export/exporter.go b/metrics/internal/export/exporter.go new file mode 100644 index 0000000..44e4256 --- /dev/null +++ b/metrics/internal/export/exporter.go @@ -0,0 +1,42 @@ +package export + +import ( + "io" + "os" +) + +type Exporter interface { + Export(v interface{}) error +} + +type WriterExporter struct { + w io.Writer + encoder Encoder +} + +func (s *WriterExporter) Export(v interface{}) error { + return s.encoder.Encode(s.w, v) +} + +func NewWriterExporter(w io.Writer, e Encoder) (*WriterExporter, error) { + return &WriterExporter{w: w, encoder: e}, nil +} + +type FileExporter struct { + encoder Encoder + filename string +} + +func (f *FileExporter) Export(v interface{}) error { + file, err := os.Create(f.filename) + if err != nil { + return err + } + defer file.Close() + + return f.encoder.Encode(file, v) +} + +func NewFileExporter(e Encoder, f string) (*FileExporter, error) { + return &FileExporter{encoder: e, filename: f}, nil +} diff --git a/metrics/internal/factory/factory.go b/metrics/internal/factory/factory.go new file mode 100644 index 0000000..0c6e05f --- /dev/null +++ b/metrics/internal/factory/factory.go @@ -0,0 +1,68 @@ +package factory + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/config" + "github.com/mozilla-services/rapid-release-model/metrics/internal/export" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/shurcooL/githubv4" + "golang.org/x/oauth2" +) + +// Factory is used to create dependencies for the CLI application +type Factory struct { + NewEncoder func() (export.Encoder, error) + NewExporter func() (export.Exporter, error) + NewGitHubHTTPClient func() (*http.Client, error) + NewGitHubGraphQLClient func() (github.GraphQLClient, error) + NewGitHubRepo func() (*github.Repo, error) +} + +// NewFactory creates the default Factory for the CLI application +func NewFactory(ctx context.Context) *Factory { + f := new(Factory) + + f.NewEncoder = func() (export.Encoder, error) { + return &export.JSONEcoder{}, nil + } + + f.NewExporter = func() (export.Exporter, error) { + encoder, err := f.NewEncoder() + if err != nil { + return nil, err + } + return export.NewWriterExporter(os.Stdout, encoder) + } + + f.NewGitHubHTTPClient = func() (*http.Client, error) { + token, err := config.ReadFromEnvE("GITHUB", "TOKEN") + if err != nil { + return nil, fmt.Errorf("Error creating GitHub HTTP Client: %w", err) + } + src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + return oauth2.NewClient(ctx, src), nil + } + + f.NewGitHubGraphQLClient = func() (github.GraphQLClient, error) { + httpClient, err := f.NewGitHubHTTPClient() + if err != nil { + return nil, err + } + gqlClient := githubv4.NewClient(httpClient) + return gqlClient, nil + } + + f.NewGitHubRepo = func() (*github.Repo, error) { + repo := &github.Repo{ + Owner: config.ReadFromEnv("GITHUB", "REPO_OWNER"), + Name: config.ReadFromEnv("GITHUB", "REPO_NAME"), + } + return repo, nil + } + + return f +} diff --git a/metrics/internal/github/api.go b/metrics/internal/github/api.go new file mode 100644 index 0000000..347ddc1 --- /dev/null +++ b/metrics/internal/github/api.go @@ -0,0 +1,16 @@ +package github + +import ( + "context" +) + +// Represents a GitHub repo +type Repo struct { + Owner string + Name string +} + +// GraphQLClient is satisfied by the the githubv4.Client. +type GraphQLClient interface { + Query(ctx context.Context, q interface{}, variables map[string]interface{}) error +} diff --git a/metrics/internal/github/deployments.go b/metrics/internal/github/deployments.go new file mode 100644 index 0000000..c7e12bc --- /dev/null +++ b/metrics/internal/github/deployments.go @@ -0,0 +1,89 @@ +package github + +import ( + "context" + "time" + + "github.com/shurcooL/githubv4" +) + +type Deployment struct { + Description string + CreatedAt time.Time + UpdatedAt time.Time + OriginalEnvironment string + LatestEnvironment string + Task string + State string + Commit struct { + AbbreviatedOid string + } +} + +type DeploymentsQuery struct { + Repository struct { + Name string + Owner struct { + Login string + } + Deployments struct { + PageInfo struct { + HasNextPage bool + EndCursor string + } + Nodes []Deployment + } `graphql:"deployments(first: $perPage, after: $endCursor, orderBy: $orderBy, environments: $environments)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// QueryDeployments fetches information about Deployments from the GitHub GraphQL API +func QueryDeployments(gqlClient GraphQLClient, repo *Repo, limit int, envs *[]string) ([]Deployment, error) { + // Values of `first` and `last` must be within 1-100. See `Node limit` in + // GitHub's GraphQL API documentation. + perPage := limit + if limit > 100 { + perPage = 100 + } + + environments := []githubv4.String{} + + for _, e := range *envs { + environments = append(environments, githubv4.String(e)) + } + + queryVariables := map[string]interface{}{ + "owner": githubv4.String(repo.Owner), + "name": githubv4.String(repo.Name), + "perPage": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), // When paginating forwards, the cursor to continue. + "orderBy": githubv4.DeploymentOrder{Field: githubv4.DeploymentOrderFieldCreatedAt, Direction: githubv4.OrderDirectionDesc}, + "environments": environments, + } + + var deployments []Deployment + +Loop: + for { + var query DeploymentsQuery + + err := gqlClient.Query(context.Background(), &query, queryVariables) + if err != nil { + return nil, err + } + + for _, n := range query.Repository.Deployments.Nodes { + deployments = append(deployments, n) + if len(deployments) == limit { + break Loop + } + } + + if !query.Repository.Deployments.PageInfo.HasNextPage { + break + } + + queryVariables["endCursor"] = githubv4.String(query.Repository.Deployments.PageInfo.EndCursor) + } + + return deployments, nil +} diff --git a/metrics/internal/github/pull_requests.go b/metrics/internal/github/pull_requests.go new file mode 100644 index 0000000..7e76acd --- /dev/null +++ b/metrics/internal/github/pull_requests.go @@ -0,0 +1,81 @@ +package github + +import ( + "context" + "time" + + "github.com/shurcooL/githubv4" +) + +type PullRequest struct { + ID string + Number int + Title string + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt time.Time + MergedAt time.Time +} + +// GraphQL query for GitHub Pull Requests +type PullRequestsQuery struct { + Repository struct { + Name string + Owner struct { + Login string + } + PullRequests struct { + PageInfo struct { + HasNextPage bool + EndCursor string + } + Nodes []PullRequest + } `graphql:"pullRequests(states: $states, first: $perPage, after: $endCursor, orderBy: $orderBy)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// QueryPullRequests fetches information about merged PRs from the GitHub GraphQL API +func QueryPullRequests(gqlClient GraphQLClient, repo *Repo, limit int) ([]PullRequest, error) { + // Values of `first` and `last` must be within 1-100. See `Node limit` in + // GitHub's GraphQL API documentation. + perPage := limit + if limit > 100 { + perPage = 100 + } + + queryVariables := map[string]interface{}{ + "owner": githubv4.String(repo.Owner), + "name": githubv4.String(repo.Name), + "perPage": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), // When paginating forwards, the cursor to continue. + "states": []githubv4.PullRequestState{githubv4.PullRequestStateMerged}, + "orderBy": githubv4.IssueOrder{Field: githubv4.IssueOrderFieldUpdatedAt, Direction: githubv4.OrderDirectionDesc}, + } + + var pullRequests []PullRequest + +Loop: + for { + var query PullRequestsQuery + + err := gqlClient.Query(context.Background(), &query, queryVariables) + if err != nil { + return nil, err + } + + for _, n := range query.Repository.PullRequests.Nodes { + pullRequests = append(pullRequests, n) + if len(pullRequests) == limit { + break Loop + } + } + + if !query.Repository.PullRequests.PageInfo.HasNextPage { + break + } + + queryVariables["endCursor"] = githubv4.String(query.Repository.PullRequests.PageInfo.EndCursor) + } + + return pullRequests, nil +} diff --git a/metrics/internal/github/releases.go b/metrics/internal/github/releases.go new file mode 100644 index 0000000..b256f00 --- /dev/null +++ b/metrics/internal/github/releases.go @@ -0,0 +1,80 @@ +package github + +import ( + "context" + "time" + + "github.com/shurcooL/githubv4" +) + +type Release struct { + Name string + TagName string + IsDraft bool + IsLatest bool + IsPrerelease bool + Description string + CreatedAt time.Time + PublishedAt time.Time +} + +type ReleasesQuery struct { + Repository struct { + Name string + Owner struct { + Login string + } + Releases struct { + PageInfo struct { + HasNextPage bool + EndCursor string + } + Nodes []Release + } `graphql:"releases(first: $perPage, after: $endCursor, orderBy: $orderBy)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// QueryReleases fetches information about merged PRs from the GitHub GraphQL API +func QueryReleases(gqlClient GraphQLClient, repo *Repo, limit int) ([]Release, error) { + // Values of `first` and `last` must be within 1-100. See `Node limit` in + // GitHub's GraphQL API documentation. + perPage := limit + if limit > 100 { + perPage = 100 + } + + queryVariables := map[string]interface{}{ + "owner": githubv4.String(repo.Owner), + "name": githubv4.String(repo.Name), + "perPage": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), // When paginating forwards, the cursor to continue. + "orderBy": githubv4.ReleaseOrder{Field: githubv4.ReleaseOrderFieldCreatedAt, Direction: githubv4.OrderDirectionDesc}, + } + + var releases []Release + +Loop: + for { + var query ReleasesQuery + + err := gqlClient.Query(context.Background(), &query, queryVariables) + if err != nil { + return nil, err + } + + for _, n := range query.Repository.Releases.Nodes { + releases = append(releases, n) + if len(releases) == limit { + break Loop + } + } + + if !query.Repository.Releases.PageInfo.HasNextPage { + break + } + + queryVariables["endCursor"] = githubv4.String(query.Repository.Releases.PageInfo.EndCursor) + } + + return releases, nil +} diff --git a/metrics/internal/test/cmd.go b/metrics/internal/test/cmd.go new file mode 100644 index 0000000..3a4194a --- /dev/null +++ b/metrics/internal/test/cmd.go @@ -0,0 +1,49 @@ +package test + +import ( + "bytes" + "context" + "fmt" + + "github.com/mozilla-services/rapid-release-model/metrics/internal/export" + "github.com/mozilla-services/rapid-release-model/metrics/internal/factory" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/spf13/cobra" +) + +// ExecuteCmd uses the passed in function to create a command and execute it +func ExecuteCmd(newCmd func(*factory.Factory) *cobra.Command, args []string, wantVariables map[string]interface{}) (string, error) { + ctx := context.Background() + buf := new(bytes.Buffer) + + // Create CLI factory for the tests + factory := factory.NewFactory(ctx) + + // Overwrite NewExporter, so that we export to buf + factory.NewExporter = func() (export.Exporter, error) { + encoder, err := factory.NewEncoder() + if err != nil { + return nil, err + } + return export.NewWriterExporter(buf, encoder) + } + + // Overwrite NewGitHubGraphQLClient to return canned responses (fixtures) + // rather than querying the live GitHub GraphQL API. + factory.NewGitHubGraphQLClient = func() (github.GraphQLClient, error) { + repo, err := factory.NewGitHubRepo() + if err != nil { + return nil, fmt.Errorf("error creating test repo") + } + return &FakeGraphQLClient{repo: repo, wantVariables: wantVariables}, nil + } + + cmd := newCmd(factory) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs(args) + + err := cmd.ExecuteContext(ctx) + + return buf.String(), err +} diff --git a/metrics/internal/test/fixtures.go b/metrics/internal/test/fixtures.go new file mode 100644 index 0000000..271c85b --- /dev/null +++ b/metrics/internal/test/fixtures.go @@ -0,0 +1,19 @@ +package test + +import ( + "os" + "path/filepath" +) + +// Load the given file from the fixtures directory +func LoadFixture(p ...string) ([]byte, error) { + parts := append([]string{"./fixtures"}, p...) + return os.ReadFile(filepath.Join(parts...)) +} + +// Load the fixture at the given location +func NewFixture(p ...string) func() ([]byte, error) { + return func() ([]byte, error) { + return LoadFixture(p...) + } +} diff --git a/metrics/internal/test/github.go b/metrics/internal/test/github.go new file mode 100644 index 0000000..5e9d630 --- /dev/null +++ b/metrics/internal/test/github.go @@ -0,0 +1,65 @@ +package test + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/go-cmp/cmp" + "github.com/mozilla-services/rapid-release-model/metrics/internal/github" + "github.com/shurcooL/githubv4" +) + +type FakeGraphQLClient struct { + repo *github.Repo + wantVariables map[string]interface{} +} + +func (c *FakeGraphQLClient) Query(ctx context.Context, q interface{}, variables map[string]interface{}) error { + // Verify that the query is performed for the specified GitHub repo + if reqOwner := string(variables["owner"].(githubv4.String)); !cmp.Equal(reqOwner, c.repo.Owner) { + return fmt.Errorf("Repo.Owner in query variables (%v) does not match app config (%v)", reqOwner, c.repo.Owner) + } + if reqName := string(variables["name"].(githubv4.String)); !cmp.Equal(reqName, c.repo.Name) { + return fmt.Errorf("Repo.Name in query variables (%v) does not match app config (%v)", reqName, c.repo.Name) + } + + for key, want := range c.wantVariables { + if got := variables[key]; !cmp.Equal(got, want) { + return fmt.Errorf("unexpected value for GraphQL query variable %v\n%v", key, cmp.Diff(got, want)) + } + } + + var key string + + // Update this for other GraphQL queries under test. + switch v := q.(type) { + case *github.PullRequestsQuery: + key = "prs" + case *github.ReleasesQuery: + key = "releases" + case *github.DeploymentsQuery: + key = "deployments" + default: + return fmt.Errorf("unsupported query: %+v", v) + } + + // Default filename for fixtures. We don't know the endCursor before we + // perform the request. As a result, the filename for the first page does + // not contain the endCursor suffix. Subsequent requests have the suffix. + filename := "query.json" + + // The endCursor variable is set, which means we're serving the next page. + // The `after` GraphQL parameter is set to the value of `endCursor` of the + // previous request. Add the suffix to the fixture filenamme. + if c := variables["endCursor"]; c != (*githubv4.String)(nil) { + filename = fmt.Sprintf("query_%s.json", c) + } + + jsonData, err := LoadFixture(key, filename) + if err != nil { + return err + } + + return json.Unmarshal(jsonData, q) +} diff --git a/metrics/internal/test/testcase.go b/metrics/internal/test/testcase.go new file mode 100644 index 0000000..5e5d04c --- /dev/null +++ b/metrics/internal/test/testcase.go @@ -0,0 +1,111 @@ +package test + +import ( + "os" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/mozilla-services/rapid-release-model/metrics/internal/factory" + "github.com/spf13/cobra" +) + +type TestCase struct { + // Name of the test case + Name string + + // Environment variables to be set for the test case + Env map[string]string + + // Arguments to be passed to the CLI app + Args []string + + // Expected output from the CLI app + WantText string + + // Function to load extected output + WantFixture func() ([]byte, error) + + // Expect output to be written to this file + WantFile string + + // Expected values for the GraphQL query variables + WantVariables map[string]interface{} + + // Text expected in error. Empty string means no error expected. + ErrContains string +} + +// RunTests is a helper function for table-driven tests using subtests +func RunTests(t *testing.T, newCmd func(*factory.Factory) *cobra.Command, tests []TestCase) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Logf("running: metrics %s", strings.Join(tt.Args, " ")) + + // Set environment variables + if tt.Env != nil { + t.Logf("using environment: %s", tt.Env) + for k, v := range tt.Env { + t.Setenv(k, v) + } + } + + // Validate testcase configuration + if (tt.ErrContains != "" && tt.WantFixture != nil) || (tt.ErrContains != "" && tt.WantText != "") { + t.Fatalf("cannot set both errContains and wantFixture or wantText") + } + + if tt.WantFixture != nil && tt.WantText != "" { + t.Fatalf("cannot set both wantFixture and wantText") + } + + // Execute the CLI cmd with the specified args + got, err := ExecuteCmd(newCmd, tt.Args, tt.WantVariables) + + if tt.ErrContains != "" && err == nil { + t.Fatalf("cmd did not return an error. output: %v", got) + } + + if tt.ErrContains == "" && err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if tt.ErrContains != "" && err != nil && !strings.Contains(err.Error(), tt.ErrContains) { + t.Fatalf("error did not contain message\ngot: %v\nmissing: %v", err, tt.ErrContains) + } + + if tt.WantFile != "" { + export, err := os.ReadFile(tt.WantFile) + if err != nil && got == "" { + t.Fatalf("CLI did not create file at %v. buffer is empty.", tt.WantFile) + } + if err != nil && got != "" { + t.Fatalf("CLI did not create file at %v. buffer:\n%v", tt.WantFile, got) + } + // Overwrite got with the contents of the exported file + got = string(export[:]) + } + + want := tt.WantText + + if tt.WantFixture != nil { + fixtureData, err := tt.WantFixture() + if err != nil { + t.Fatalf("error loading fixture: %v", err) + } + want = string(fixtureData[:]) + } + + if want != "" { + tGot := strings.TrimSpace(got) + tWant := strings.TrimSpace(want) + + if !cmp.Equal(tGot, tWant) { + t.Fatalf("cmd returned unexpected output\n%v", cmp.Diff(tGot, tWant)) + } + } + }) + } +}