diff --git a/.goreleaser-dev.yaml b/.goreleaser-dev.yaml index 30c08ea2..2981d2cc 100644 --- a/.goreleaser-dev.yaml +++ b/.goreleaser-dev.yaml @@ -166,3 +166,12 @@ builds: no_unique_dist_dir: true tags: - fabricplugin + + - id: atlassian + main: ./internal/atlassian/cmd + binary: "plugins/blackstork/atlassian@{{ .Version }}" + ldflags: "-X main.version={{.Version}}" + gcflags: all=-N -l + no_unique_dist_dir: true + tags: + - fabricplugin diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e71e68f3..7da3db2a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -261,6 +261,20 @@ builds: tags: - fabricplugin + - id: plugin_atlassian + main: ./internal/atlassian/cmd + binary: "atlassian@{{ .Version }}" + flags: "-trimpath" + hooks: + post: + - go run ./tools/pluginmeta --namespace blackstork --version {{.Version}} patch --plugin {{.Path}} --os {{.Os}} --arch {{.Arch}} + goos: + - linux + - windows + - darwin + tags: + - fabricplugin + archives: - id: fabric format: tar.gz @@ -457,6 +471,7 @@ archives: {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} + - id: plugin_iris format: tar.gz builds: @@ -469,6 +484,18 @@ archives: {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} + - id: plugin_atlassian + format: tar.gz + builds: + - plugin_atlassian + name_template: >- + plugin_atlassian_ + {{- .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + dockers: - use: buildx goos: linux diff --git a/.mockery.yaml b/.mockery.yaml index b49bde0c..9f95a214 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -54,6 +54,10 @@ packages: AzureOpenAIClient: MicrosoftGraphClient: MicrosoftSecurityClient: + github.com/blackstork-io/fabric/internal/atlassian/client: + config: + interfaces: + Client: github.com/blackstork-io/fabric/internal/crowdstrike: config: interfaces: diff --git a/docs/plugins/atlassian/_index.md b/docs/plugins/atlassian/_index.md new file mode 100644 index 00000000..4f773995 --- /dev/null +++ b/docs/plugins/atlassian/_index.md @@ -0,0 +1,34 @@ +--- +title: blackstork/atlassian +weight: 20 +plugin: + name: blackstork/atlassian + description: "The `atlassian` plugin for Atlassian Cloud." + tags: [] + version: "v0.4.2" + source_github: "https://github.com/blackstork-io/fabric/tree/main/internal/atlassian/" +type: docs +hideInMenu: true +--- + +{{< plugin-header "blackstork/atlassian" "atlassian" "v0.4.2" >}} + +## Description +The `atlassian` plugin for Atlassian Cloud. + +## Installation + +To install the plugin, add it to `plugin_versions` map in the Fabric global configuration block (see [Global configuration]({{< ref "configs.md#global-configuration" >}}) for more details), with a version constraint restricting which available versions of the plugin the codebase is compatible with: + +```hcl +fabric { + plugin_versions = { + "blackstork/atlassian" = ">= v0.4.2" + } +} +``` + + +## Data sources + +{{< plugin-resources "atlassian" "data-source" >}} diff --git a/docs/plugins/atlassian/data-sources/jira_issues.md b/docs/plugins/atlassian/data-sources/jira_issues.md new file mode 100644 index 00000000..8ed5dbb3 --- /dev/null +++ b/docs/plugins/atlassian/data-sources/jira_issues.md @@ -0,0 +1,114 @@ +--- +title: "`jira_issues` data source" +plugin: + name: blackstork/atlassian + description: "Retrieve issues from Jira" + tags: [] + version: "v0.4.2" + source_github: "https://github.com/blackstork-io/fabric/tree/main/internal/atlassian/" +resource: + type: data-source +type: docs +--- + +{{< breadcrumbs 2 >}} + +{{< plugin-resource-header "blackstork/atlassian" "atlassian" "v0.4.2" "jira_issues" "data source" >}} + +## Description +Retrieve issues from Jira. + +## Installation + +To use `jira_issues` data source, you must install the plugin `blackstork/atlassian`. + +To install the plugin, add the full plugin name to the `plugin_versions` map in the Fabric global configuration block (see [Global configuration]({{< ref "configs.md#global-configuration" >}}) for more details), as shown below: + +```hcl +fabric { + plugin_versions = { + "blackstork/atlassian" = ">= v0.4.2" + } +} +``` + +Note the version constraint set for the plugin. + +## Configuration + +The data source supports the following configuration arguments: + +```hcl +config data jira_issues { + # Account Domain. + # + # Required string. + # Must be non-empty + # For example: + domain = "some string" + + # Account Email. + # + # Required string. + # Must be non-empty + # For example: + account_email = "some string" + + # API Token. + # + # Required string. + # Must be non-empty + # For example: + api_token = "some string" +} +``` + +## Usage + +The data source supports the following execution arguments: + +```hcl +data jira_issues { + # Use expand to include additional information about issues in the response. + # + # Optional string. + # Must be one of: "renderedFields", "names", "schema", "changelog" + # For example: + # expand = "names" + # + # Default value: + expand = null + + # A list of fields to return for each issue. + # + # Optional list of string. + # For example: + # fields = ["*all"] + # + # Default value: + fields = null + + # A JQL expression. For performance reasons, this field requires a bounded query. A bounded query is a query with a search restriction. + # + # Optional string. + # For example: + # jql = "order by key desc" + # + # Default value: + jql = null + + # A list of up to 5 issue properties to include in the results. + # + # Optional list of string. + # Must have a length of at most 5 + # Default value: + properties = [] + + # Size limit to retrieve. + # + # Optional number. + # Must be >= 0 + # Default value: + size = 0 +} +``` \ No newline at end of file diff --git a/docs/plugins/plugins.json b/docs/plugins/plugins.json index 7239c1cb..b7ce4599 100644 --- a/docs/plugins/plugins.json +++ b/docs/plugins/plugins.json @@ -1,4 +1,27 @@ [ + { + "name": "blackstork/atlassian", + "version": "v0.4.2", + "shortname": "atlassian", + "resources": [ + { + "name": "jira_issues", + "type": "data-source", + "config_params": [ + "account_email", + "api_token", + "domain" + ], + "arguments": [ + "expand", + "fields", + "jql", + "properties", + "size" + ] + } + ] + }, { "name": "blackstork/builtin", "version": "v0.4.2", diff --git a/examples/templates/atlassian/example.fabric b/examples/templates/atlassian/example.fabric new file mode 100644 index 00000000..efe922dd --- /dev/null +++ b/examples/templates/atlassian/example.fabric @@ -0,0 +1,28 @@ +fabric { + plugin_versions = { + "blackstork/atlassian" = ">= 0.5 < 1.0 || 0.5.0-rev0" + } +} + +config data jira_issues { + domain = env.JIRA_DOMAIN + account_email = env.JIRA_ACCOUNT_EMAIL + api_token = env.JIRA_API_TOKEN +} + +document "example" { + title = "Using atlassian plugin" + data jira_issues "my_issues" { + expand = "names" + fields = ["*all"] + jql = "project = TEST" + size = 5 + } + content title { + value = "My Jira Issues" + } + content list { + item_template = "{{.key}}: {{.fields.summary}}" + items = query_jq(".data.jira_issues.my_issues") + } +} \ No newline at end of file diff --git a/internal/atlassian/client/client.go b/internal/atlassian/client/client.go new file mode 100644 index 00000000..8c6bbc4c --- /dev/null +++ b/internal/atlassian/client/client.go @@ -0,0 +1,98 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/url" + "time" +) + +type Client interface { + SearchIssues(ctx context.Context, req *SearchIssuesReq) (*SearchIssuesRes, error) +} + +type client struct { + apiURL string + apiToken string + accountEmail string +} + +func (c *client) auth(r *http.Request) { + r.SetBasicAuth(c.accountEmail, c.apiToken) +} + +func (c *client) makeHTTPClient() *http.Client { + httpClient := &http.Client{ + Timeout: 15 * time.Second, + } + + return httpClient +} + +func (c *client) makeURL(path ...string) (*url.URL, error) { + addr, err := url.JoinPath(c.apiURL, path...) + if err != nil { + return nil, err + } + return url.Parse(addr) +} + +func New(apiURL, accountEmail, apiToken string) Client { + return &client{ + apiURL: apiURL, + accountEmail: accountEmail, + apiToken: apiToken, + } +} + +func (c *client) handleError(res *http.Response) error { + var clientErr Error + + if err := json.NewDecoder(res.Body).Decode(&clientErr); err != nil { + return err + } + + return &clientErr +} + +func (c *client) SearchIssues(ctx context.Context, req *SearchIssuesReq) (*SearchIssuesRes, error) { + u, err := c.makeURL("/rest/api/3/search/jql") + if err != nil { + return nil, err + } + + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + r, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Accept", "application/json") + c.auth(r) + + client := c.makeHTTPClient() + res, err := client.Do(r) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, c.handleError(res) + } + + var data SearchIssuesRes + if err := json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + return &data, nil +} diff --git a/internal/atlassian/client/client_test.go b/internal/atlassian/client/client_test.go new file mode 100644 index 00000000..6723e0dd --- /dev/null +++ b/internal/atlassian/client/client_test.go @@ -0,0 +1,108 @@ +package client + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ClientTestSuite struct { + suite.Suite + ctx context.Context + cancel context.CancelFunc +} + +func (s *ClientTestSuite) SetupTest() { + s.ctx, s.cancel = context.WithCancel(context.Background()) +} + +func (s *ClientTestSuite) TearDownTest() { + s.cancel() +} + +func TestClientTestSuite(t *testing.T) { + suite.Run(t, new(ClientTestSuite)) +} + +func (s *ClientTestSuite) mock(fn http.HandlerFunc, accountEmail, apiToken string) (Client, *httptest.Server) { + srv := httptest.NewServer(fn) + cli := New(srv.URL, accountEmail, apiToken) + return cli, srv +} + +func (s *ClientTestSuite) TestSearchIssuesC() { + client, srv := s.mock(func(w http.ResponseWriter, r *http.Request) { + s.Equal("/rest/api/3/search/jql", r.URL.Path) + s.Equal(http.MethodPost, r.Method) + s.Equal("application/json", r.Header.Get("Content-Type")) + s.Equal("application/json", r.Header.Get("Accept")) + + user, pass, ok := r.BasicAuth() + s.True(ok) + s.Equal("test-email", user) + s.Equal("test-token", pass) + + body, err := io.ReadAll(r.Body) + s.Require().NoError(err) + defer r.Body.Close() + s.JSONEq(`{ + "expand": "names", + "fields": [ + "*all" + ], + "jql": "project = TEST", + "maxResults": 15, + "properties": ["test_property_1"], + "nextPageToken": "test_page_token_1" + }`, string(body)) + w.Write([]byte(`{ + "nextPageToken": "test_page_token_2", + "issues": [ + { + "any": "data" + } + ] + }`)) + }, "test-email", "test-token") + defer srv.Close() + + req := SearchIssuesReq{ + Expand: String("names"), + Fields: []string{"*all"}, + JQL: String("project = TEST"), + MaxResults: Int(15), + Properties: []string{"test_property_1"}, + NextPageToken: String("test_page_token_1"), + } + + result, err := client.SearchIssues(s.ctx, &req) + s.NoError(err) + s.Equal(&SearchIssuesRes{ + NextPageToken: String("test_page_token_2"), + Issues: []any{ + map[string]any{ + "any": "data", + }, + }, + }, result) +} + +func (s *ClientTestSuite) TestSearchIssuesError() { + client, srv := s.mock(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{ + "errorMessages" : [ + "Test Error" + ] + }`)) + }, "", "") + defer srv.Close() + _, err := client.SearchIssues(s.ctx, &SearchIssuesReq{}) + s.Equal(&Error{ + ErrorMessages: []string{"Test Error"}, + }, err) +} diff --git a/internal/atlassian/client/dto.go b/internal/atlassian/client/dto.go new file mode 100644 index 00000000..16b7613f --- /dev/null +++ b/internal/atlassian/client/dto.go @@ -0,0 +1,35 @@ +package client + +import ( + "strings" +) + +func String(s string) *string { + return &s +} + +func Int(i int) *int { + return &i +} + +type Error struct { + ErrorMessages []string `json:"errorMessages"` +} + +func (err *Error) Error() string { + return strings.Join(err.ErrorMessages, " ") +} + +type SearchIssuesReq struct { + Expand *string `json:"expand,omitempty"` + Fields []string `json:"fields,omitempty"` + JQL *string `json:"jql,omitempty"` + Properties []string `json:"properties,omitempty"` + NextPageToken *string `json:"nextPageToken,omitempty"` + MaxResults *int `json:"maxResults,omitempty"` +} + +type SearchIssuesRes struct { + NextPageToken *string `json:"nextPageToken,omitempty"` + Issues []any `json:"issues"` +} diff --git a/internal/atlassian/cmd/main.go b/internal/atlassian/cmd/main.go new file mode 100644 index 00000000..7315a4cc --- /dev/null +++ b/internal/atlassian/cmd/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/blackstork-io/fabric/internal/atlassian" + pluginapiv1 "github.com/blackstork-io/fabric/plugin/pluginapi/v1" +) + +var version string + +func main() { + pluginapiv1.Serve( + atlassian.Plugin(version, atlassian.DefaultClientLoader), + ) +} diff --git a/internal/atlassian/data_jira_issues.go b/internal/atlassian/data_jira_issues.go new file mode 100644 index 00000000..e3a76fde --- /dev/null +++ b/internal/atlassian/data_jira_issues.go @@ -0,0 +1,164 @@ +package atlassian + +import ( + "context" + + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + + "github.com/blackstork-io/fabric/internal/atlassian/client" + "github.com/blackstork-io/fabric/pkg/diagnostics" + "github.com/blackstork-io/fabric/plugin" + "github.com/blackstork-io/fabric/plugin/dataspec" + "github.com/blackstork-io/fabric/plugin/dataspec/constraint" + "github.com/blackstork-io/fabric/plugin/plugindata" +) + +func makeJiraIssuesDataSource(loader ClientLoadFn) *plugin.DataSource { + return &plugin.DataSource{ + Doc: "Retrieve issues from Jira.", + DataFunc: searchJiraIssuesData(loader), + Config: &dataspec.RootSpec{ + Attrs: []*dataspec.AttrSpec{ + { + Name: "domain", + Type: cty.String, + Constraints: constraint.RequiredMeaningful, + Doc: "Account Domain.", + }, + { + Name: "account_email", + Type: cty.String, + Secret: true, + Constraints: constraint.RequiredMeaningful, + Doc: "Account Email.", + }, + { + Name: "api_token", + Type: cty.String, + Secret: true, + Constraints: constraint.RequiredMeaningful, + Doc: "API Token.", + }, + }, + }, + Args: &dataspec.RootSpec{ + Attrs: []*dataspec.AttrSpec{ + { + Name: "expand", + Type: cty.String, + Doc: "Use expand to include additional information about issues in the response.", + OneOf: constraint.OneOf{ + cty.StringVal("renderedFields"), + cty.StringVal("names"), + cty.StringVal("schema"), + cty.StringVal("changelog"), + }, + ExampleVal: cty.StringVal("names"), + }, + { + Name: "fields", + Type: cty.List(cty.String), + Doc: "A list of fields to return for each issue.", + ExampleVal: cty.ListVal([]cty.Value{ + cty.StringVal("*all"), + }), + }, + { + Name: "jql", + Type: cty.String, + Doc: "A JQL expression. For performance reasons, this field requires a bounded query. A bounded query is a query with a search restriction.", + ExampleVal: cty.StringVal("order by key desc"), + }, + { + Name: "properties", + Type: cty.List(cty.String), + Doc: "A list of up to 5 issue properties to include in the results.", + MaxInclusive: cty.NumberIntVal(5), + DefaultVal: cty.ListValEmpty(cty.String), + }, + { + Name: "size", + Type: cty.Number, + Doc: "Size limit to retrieve.", + MinInclusive: cty.NumberIntVal(0), + Constraints: constraint.NonNull, + DefaultVal: cty.NumberIntVal(0), + }, + }, + }, + } +} + +func searchJiraIssuesData(loader ClientLoadFn) plugin.RetrieveDataFunc { + return func(ctx context.Context, params *plugin.RetrieveDataParams) (plugindata.Data, diagnostics.Diag) { + cli, err := parseConfig(params.Config, loader) + if err != nil { + return nil, diagnostics.Diag{{ + Severity: hcl.DiagError, + Summary: "Failed to parse configuration", + }} + } + req, err := parseSearchIssuesReq(params.Args) + if err != nil { + return nil, diagnostics.Diag{{ + Severity: hcl.DiagError, + Summary: "Failed to parse arguments", + }} + } + + num, _ := params.Args.GetAttrVal("size").AsBigFloat().Int64() + size := int(num) + + var issues plugindata.List + for { + res, err := cli.SearchIssues(ctx, req) + if err != nil { + return nil, handleClientError(err) + } + for _, v := range res.Issues { + data, err := plugindata.ParseAny(v) + if err != nil { + return nil, diagnostics.Diag{{ + Severity: hcl.DiagError, + Summary: "Failed to parse data", + }} + } + issues = append(issues, data) + if size > 0 && len(issues) == size { + break + } + } + if (size > 0 && len(issues) == size) || res.NextPageToken == nil { + break + } + req.NextPageToken = res.NextPageToken + } + return issues, nil + } +} + +func parseSearchIssuesReq(args *dataspec.Block) (*client.SearchIssuesReq, error) { + req := &client.SearchIssuesReq{} + if attr := args.GetAttrVal("expand"); !attr.IsNull() { + req.Expand = client.String(attr.AsString()) + } + if attr := args.GetAttrVal("jql"); !attr.IsNull() { + req.JQL = client.String(attr.AsString()) + } + if attr := args.GetAttrVal("fields"); !attr.IsNull() { + for _, field := range attr.AsValueSlice() { + if field.IsNull() { + continue + } + req.Fields = append(req.Fields, field.AsString()) + } + } + for _, property := range args.GetAttrVal("properties").AsValueSlice() { + if property.IsNull() { + continue + } + req.Properties = append(req.Properties, property.AsString()) + } + return req, nil +} diff --git a/internal/atlassian/data_jira_issues_test.go b/internal/atlassian/data_jira_issues_test.go new file mode 100644 index 00000000..a6ada8a7 --- /dev/null +++ b/internal/atlassian/data_jira_issues_test.go @@ -0,0 +1,146 @@ +package atlassian + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "github.com/zclconf/go-cty/cty" + + "github.com/blackstork-io/fabric/internal/atlassian/client" + client_mocks "github.com/blackstork-io/fabric/mocks/internalpkg/atlassian/client" + "github.com/blackstork-io/fabric/plugin" + "github.com/blackstork-io/fabric/plugin/plugindata" + "github.com/blackstork-io/fabric/plugin/plugintest" +) + +type JiraIssuesDataSourceTestSuite struct { + suite.Suite + + plugin *plugin.Schema + ctx context.Context + cli *client_mocks.Client + storedApiURL string + storedAccountEmail string + storedApiToken string +} + +func TestJiraIssuesDataSourceTestSuite(t *testing.T) { + suite.Run(t, new(JiraIssuesDataSourceTestSuite)) +} + +func (s *JiraIssuesDataSourceTestSuite) SetupSuite() { + s.plugin = Plugin("v0.0.0", func(apiURL, accountEmail, apiToken string) client.Client { + s.storedApiURL = apiURL + s.storedAccountEmail = accountEmail + s.storedApiToken = apiToken + return s.cli + }) + s.ctx = context.Background() +} + +func (s *JiraIssuesDataSourceTestSuite) SetupTest() { + s.cli = &client_mocks.Client{} +} + +func (s *JiraIssuesDataSourceTestSuite) TearDownTest() { + s.cli.AssertExpectations(s.T()) +} + +func (s *JiraIssuesDataSourceTestSuite) TestSchema() { + s.Require().NotNil(s.plugin.DataSources["jira_issues"]) + s.NotNil(s.plugin.DataSources["jira_issues"].Config) + s.NotNil(s.plugin.DataSources["jira_issues"].Args) + s.NotNil(s.plugin.DataSources["jira_issues"].DataFunc) +} + +func (s *JiraIssuesDataSourceTestSuite) TestLimit() { + s.cli.On("SearchIssues", mock.Anything, &client.SearchIssuesReq{}).Return(&client.SearchIssuesRes{ + Issues: []any{ + map[string]any{ + "id": "1", + }, + }, + }, nil) + res, diags := s.plugin.RetrieveData(s.ctx, "jira_issues", &plugin.RetrieveDataParams{ + Config: plugintest.NewTestDecoder(s.T(), s.plugin.DataSources["jira_issues"].Config). + SetAttr("domain", cty.StringVal("test_domain")). + SetAttr("account_email", cty.StringVal("test_account_email")). + SetAttr("api_token", cty.StringVal("test_api_token")). + Decode(), + Args: plugintest.NewTestDecoder(s.T(), s.plugin.DataSources["jira_issues"].Args). + Decode(), + }) + s.Equal("https://test_domain.atlassian.net", s.storedApiURL) + s.Equal("test_account_email", s.storedAccountEmail) + s.Equal("test_api_token", s.storedApiToken) + s.Len(diags, 0) + s.Equal(plugindata.List{ + plugindata.Map{ + "id": plugindata.String("1"), + }, + }, res) +} + +func (s *JiraIssuesDataSourceTestSuite) TestFull() { + s.cli.On("SearchIssues", mock.Anything, &client.SearchIssuesReq{ + Expand: client.String("names"), + Fields: []string{"*all"}, + JQL: client.String("project = TEST"), + Properties: []string{"example"}, + }).Return(&client.SearchIssuesRes{ + NextPageToken: client.String("page_2"), + Issues: []any{ + map[string]any{ + "id": "1", + }, + }, + }, nil) + s.cli.On("SearchIssues", mock.Anything, &client.SearchIssuesReq{ + NextPageToken: client.String("page_2"), + Expand: client.String("names"), + Fields: []string{"*all"}, + JQL: client.String("project = TEST"), + Properties: []string{"example"}, + }).Return(&client.SearchIssuesRes{ + Issues: []any{ + map[string]any{ + "id": "2", + }, + map[string]any{ + "id": "3", + }, + }, + }, nil) + res, diags := s.plugin.RetrieveData(s.ctx, "jira_issues", &plugin.RetrieveDataParams{ + Config: plugintest.NewTestDecoder(s.T(), s.plugin.DataSources["jira_issues"].Config). + SetAttr("domain", cty.StringVal("test_domain")). + SetAttr("account_email", cty.StringVal("test_account_email")). + SetAttr("api_token", cty.StringVal("test_api_token")). + Decode(), + Args: plugintest.NewTestDecoder(s.T(), s.plugin.DataSources["jira_issues"].Args). + SetAttr("expand", cty.StringVal("names")). + SetAttr("fields", cty.ListVal([]cty.Value{ + cty.StringVal("*all"), + })). + SetAttr("properties", cty.ListVal([]cty.Value{ + cty.StringVal("example"), + })). + SetAttr("jql", cty.StringVal("project = TEST")). + SetAttr("size", cty.NumberIntVal(2)). + Decode(), + }) + s.Equal("https://test_domain.atlassian.net", s.storedApiURL) + s.Equal("test_account_email", s.storedAccountEmail) + s.Equal("test_api_token", s.storedApiToken) + s.Len(diags, 0) + s.Equal(plugindata.List{ + plugindata.Map{ + "id": plugindata.String("1"), + }, + plugindata.Map{ + "id": plugindata.String("2"), + }, + }, res) +} diff --git a/internal/atlassian/plugin.go b/internal/atlassian/plugin.go new file mode 100644 index 00000000..5fd37920 --- /dev/null +++ b/internal/atlassian/plugin.go @@ -0,0 +1,58 @@ +package atlassian + +import ( + "errors" + "fmt" + "strings" + + "github.com/hashicorp/hcl/v2" + + "github.com/blackstork-io/fabric/internal/atlassian/client" + "github.com/blackstork-io/fabric/pkg/diagnostics" + "github.com/blackstork-io/fabric/plugin" + "github.com/blackstork-io/fabric/plugin/dataspec" +) + +type ClientLoadFn func(url, accountEmail, apiToken string) client.Client + +var DefaultClientLoader ClientLoadFn = client.New + +func Plugin(version string, loader ClientLoadFn) *plugin.Schema { + if loader == nil { + loader = DefaultClientLoader + } + return &plugin.Schema{ + Name: "blackstork/atlassian", + Doc: "The `atlassian` plugin for Atlassian Cloud.", + Version: version, + DataSources: plugin.DataSources{ + "jira_issues": makeJiraIssuesDataSource(loader), + }, + } +} + +func parseConfig(cfg *dataspec.Block, loader ClientLoadFn) (client.Client, error) { + if cfg == nil { + return nil, fmt.Errorf("configuration is required") + } + domain := cfg.GetAttrVal("domain").AsString() + apiURL := fmt.Sprintf("https://%s.atlassian.net", domain) + accountEmail := cfg.GetAttrVal("account_email").AsString() + apiToken := cfg.GetAttrVal("api_token").AsString() + return loader(apiURL, accountEmail, apiToken), nil +} + +func handleClientError(err error) diagnostics.Diag { + var clientErr *client.Error + if errors.As(err, &clientErr) { + return diagnostics.Diag{{ + Severity: hcl.DiagError, + Summary: "Failed to call Atlassian API", + Detail: strings.Join(clientErr.ErrorMessages, " "), + }} + } + return diagnostics.Diag{{ + Severity: hcl.DiagError, + Summary: "Unknown error while calling Atlassian API", + }} +} diff --git a/internal/atlassian/plugin_test.go b/internal/atlassian/plugin_test.go new file mode 100644 index 00000000..6c265a4e --- /dev/null +++ b/internal/atlassian/plugin_test.go @@ -0,0 +1,14 @@ +package atlassian + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPlugin_Schema(t *testing.T) { + schema := Plugin("1.2.3", nil) + assert.Equal(t, "blackstork/atlassian", schema.Name) + assert.Equal(t, "1.2.3", schema.Version) + assert.NotNil(t, schema.DataSources["jira_issues"]) +} diff --git a/internal/plugin_validity_test.go b/internal/plugin_validity_test.go index fd094c68..efc5feda 100644 --- a/internal/plugin_validity_test.go +++ b/internal/plugin_validity_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/blackstork-io/fabric/internal/atlassian" "github.com/blackstork-io/fabric/internal/builtin" "github.com/blackstork-io/fabric/internal/elastic" "github.com/blackstork-io/fabric/internal/github" @@ -48,6 +49,7 @@ func TestAllPluginSchemaValidity(t *testing.T) { snyk.Plugin(ver, nil), microsoft.Plugin(ver, nil, nil, nil, nil), iris.Plugin(ver, nil), + atlassian.Plugin(ver, nil), } for _, p := range plugins { p := p diff --git a/mocks/internalpkg/atlassian/client/client.go b/mocks/internalpkg/atlassian/client/client.go new file mode 100644 index 00000000..b127d631 --- /dev/null +++ b/mocks/internalpkg/atlassian/client/client.go @@ -0,0 +1,97 @@ +// Code generated by mockery v2.42.1. DO NOT EDIT. + +package client_mocks + +import ( + context "context" + + client "github.com/blackstork-io/fabric/internal/atlassian/client" + + mock "github.com/stretchr/testify/mock" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +type Client_Expecter struct { + mock *mock.Mock +} + +func (_m *Client) EXPECT() *Client_Expecter { + return &Client_Expecter{mock: &_m.Mock} +} + +// SearchIssues provides a mock function with given fields: ctx, req +func (_m *Client) SearchIssues(ctx context.Context, req *client.SearchIssuesReq) (*client.SearchIssuesRes, error) { + ret := _m.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for SearchIssues") + } + + var r0 *client.SearchIssuesRes + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *client.SearchIssuesReq) (*client.SearchIssuesRes, error)); ok { + return rf(ctx, req) + } + if rf, ok := ret.Get(0).(func(context.Context, *client.SearchIssuesReq) *client.SearchIssuesRes); ok { + r0 = rf(ctx, req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.SearchIssuesRes) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *client.SearchIssuesReq) error); ok { + r1 = rf(ctx, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Client_SearchIssues_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SearchIssues' +type Client_SearchIssues_Call struct { + *mock.Call +} + +// SearchIssues is a helper method to define mock.On call +// - ctx context.Context +// - req *client.SearchIssuesReq +func (_e *Client_Expecter) SearchIssues(ctx interface{}, req interface{}) *Client_SearchIssues_Call { + return &Client_SearchIssues_Call{Call: _e.mock.On("SearchIssues", ctx, req)} +} + +func (_c *Client_SearchIssues_Call) Run(run func(ctx context.Context, req *client.SearchIssuesReq)) *Client_SearchIssues_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*client.SearchIssuesReq)) + }) + return _c +} + +func (_c *Client_SearchIssues_Call) Return(_a0 *client.SearchIssuesRes, _a1 error) *Client_SearchIssues_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Client_SearchIssues_Call) RunAndReturn(run func(context.Context, *client.SearchIssuesReq) (*client.SearchIssuesRes, error)) *Client_SearchIssues_Call { + _c.Call.Return(run) + return _c +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/tools/docgen/main.go b/tools/docgen/main.go index edf435db..4c762a03 100644 --- a/tools/docgen/main.go +++ b/tools/docgen/main.go @@ -15,6 +15,7 @@ import ( "github.com/Masterminds/sprig/v3" "github.com/spf13/pflag" + "github.com/blackstork-io/fabric/internal/atlassian" "github.com/blackstork-io/fabric/internal/builtin" "github.com/blackstork-io/fabric/internal/crowdstrike" "github.com/blackstork-io/fabric/internal/elastic" @@ -282,6 +283,7 @@ func main() { microsoft.Plugin(version, nil, nil, nil, nil), crowdstrike.Plugin(version, nil), iris.Plugin(version, nil), + atlassian.Plugin(version, nil), } // generate markdown for each plugin for _, p := range plugins {