diff --git a/application/jira/v0/README.mdx b/application/jira/v0/README.mdx
new file mode 100644
index 00000000..cd9e8d56
--- /dev/null
+++ b/application/jira/v0/README.mdx
@@ -0,0 +1,186 @@
+---
+title: "Jira"
+lang: "en-US"
+draft: false
+description: "Learn about how to set up a VDP Jira component https://github.com/instill-ai/instill-core"
+---
+
+The Jira component is an application component that allows users to do anything available on Jira.
+It can carry out the following tasks:
+
+- [List Boards](#list-boards)
+- [List Issues](#list-issues)
+- [List Sprints](#list-sprints)
+- [Get Issue](#get-issue)
+- [Get Sprint](#get-sprint)
+
+
+
+## Release Stage
+
+`Alpha`
+
+
+
+## Configuration
+
+The component configuration is defined and maintained [here](https://github.com/instill-ai/component/blob/main/application/jira/v0/config/definition.json).
+
+
+
+
+## Setup
+
+
+| Field | Field ID | Type | Note |
+| :--- | :--- | :--- | :--- |
+| Token (required) | `token` | string | Fill in your Jira API token. You can generate one from your Jira account "settings > security > API tokens". |
+| Base URL (required) | `base-url` | string | Fill in your Jira base URL. For example, if your Jira URL is https://mycompany.atlassian.net, then your base URL is https://mycompany.atlassian.net. |
+| Email (required) | `email` | string | Fill in your Jira email address. |
+
+
+
+
+## Supported Tasks
+
+### List Boards
+
+List all boards in Jira
+
+
+| Input | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Task ID (required) | `task` | string | `TASK_LIST_BOARDS` |
+| Project Key or ID | `project-key-or-id` | string | This filters results to boards that are relevant to a project. Relevance meaning that the JQL filter defined in board contains a reference to a project. |
+| Board Type | `board-type` | string | The type of board, can be: scrum, kanban, simple. Default is simple |
+| Name | `name` | string | Name filters results to boards that match or partially match the specified name. Default is empty |
+| Start At | `start-at` | integer | The starting index of the returned boards. Base index: 0. Default is 0 |
+| Max Results | `max-results` | integer | The maximum number of boards to return. Default is 50 |
+
+
+
+| Output | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Boards (optional) | `boards` | array[object] | A array of boards in Jira |
+| Start At | `start-at` | integer | The starting index of the returned boards. Base index: 0 |
+| Max Results | `max-results` | integer | The maximum number of boards |
+| Total | `total` | integer | The total number of boards |
+| Is Last | `is-last` | boolean | Whether the last board is reached |
+
+
+
+
+
+
+### List Issues
+
+List issues in Jira
+
+
+| Input | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Task ID (required) | `task` | string | `TASK_LIST_ISSUES` |
+| Board ID (required) | `board-id` | integer | The ID of the board |
+| Range | `range` | object | Choose the range of issues to return. Default is `all` |
+| Start At | `start-at` | integer | The starting index of the returned boards. Base index: 0. Default is 0 |
+| Max Results | `max-results` | integer | The maximum number of boards to return. Default is 50 |
+
+
+
+| Output | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Issues (optional) | `issues` | array[object] | A array of issues in Jira |
+| Start At | `start-at` | integer | The starting index of the returned boards. Base index: 0 |
+| Max Results | `max-results` | integer | The maximum number of boards |
+| Total | `total` | integer | The total number of boards |
+
+
+
+
+
+
+### List Sprints
+
+List sprints in Jira
+
+
+| Input | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Task ID (required) | `task` | string | `TASK_LIST_SPRINTS` |
+| Board ID (required) | `board-id` | integer | The ID of the board |
+| Start At | `start-at` | integer | The starting index of the returned boards. Base index: 0. Default is 0 |
+| Max Results | `max-results` | integer | The maximum number of boards to return. Default is 50 |
+
+
+
+| Output | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Sprints (optional) | `sprints` | array[object] | A array of sprints in Jira |
+| Start At | `start-at` | integer | The starting index of the returned boards. Base index: 0 |
+| Max Results | `max-results` | integer | The maximum number of boards |
+| Total | `total` | integer | The total number of boards |
+
+
+
+
+
+
+### Get Issue
+
+Get an issue in Jira
+
+
+| Input | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Task ID (required) | `task` | string | `TASK_GET_ISSUE` |
+| Issue ID or Key (required) | `issue-id-or-key` | string | The ID or key of the issue |
+| Update History | `update-history` | boolean | Whether the project in which the issue is created is added to the user's Recently viewed project list, as shown under Projects in Jira. |
+
+
+
+| Output | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| ID | `id` | string | The ID of the issue |
+| Key | `key` | string | The key of the issue |
+| Self | `self` | string | The URL of the issue |
+| Fields | `fields` | object | The fields of the issue. All navigable and Agile fields are returned |
+| Issue Type (optional) | `issue-type` | string | The type of the issue, can be: `Task`, `Epic` |
+| Summary (optional) | `summary` | string | The summary of the issue |
+| Description (optional) | `description` | string | The description of the issue |
+| Status (optional) | `status` | string | The status of the issue, can be: `To Do`, `In Progress`, `Done` |
+
+
+
+
+
+
+### Get Sprint
+
+Get a sprint in Jira
+
+
+| Input | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| Task ID (required) | `task` | string | `TASK_GET_SPRINT` |
+| Sprint ID (required) | `sprint-id` | integer | The ID of the sprint. The sprint will only be returned if you can view the board that the sprint was created on, or view at least one of the issues in the sprint. |
+
+
+
+| Output | ID | Type | Description |
+| :--- | :--- | :--- | :--- |
+| ID (optional) | `id` | integer | The ID of the sprint |
+| Self (optional) | `self` | string | The URL of the sprint |
+| State (optional) | `state` | string | The state of the sprint, can be: `active`, `closed`, `future` |
+| Name (optional) | `name` | string | The name of the sprint |
+| Start Date (optional) | `start-date` | string | The start date of the sprint. In the RFC3339 format, e.g. 2018-03-05T00:00:00Z |
+| End Date (optional) | `end-date` | string | The end date of the sprint. In the RFC3339 format, e.g. 2018-03-05T00:00:00Z |
+| Complete Date (optional) | `complete-date` | string | The complete date of the sprint. In the RFC3339 format, e.g. 2018-03-05T00:00:00Z |
+| Origin Board ID (optional) | `origin-board-id` | integer | The ID of the origin board |
+| Goal (optional) | `goal` | string | The Goal of the sprint |
+
+
+
+
+
+
+
diff --git a/application/jira/v0/assets/jira.svg b/application/jira/v0/assets/jira.svg
new file mode 100644
index 00000000..aca92e1b
--- /dev/null
+++ b/application/jira/v0/assets/jira.svg
@@ -0,0 +1,15 @@
+
diff --git a/application/jira/v0/boards.go b/application/jira/v0/boards.go
new file mode 100644
index 00000000..812e8895
--- /dev/null
+++ b/application/jira/v0/boards.go
@@ -0,0 +1,109 @@
+package jira
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+
+ "github.com/instill-ai/component/base"
+ "github.com/instill-ai/x/errmsg"
+ "google.golang.org/protobuf/types/known/structpb"
+)
+
+type Board struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Self string `json:"self"`
+ BoardType string `json:"type"`
+}
+
+type ListBoardsInput struct {
+ ProjectKeyOrID string `json:"project-key-or-id,omitempty" api:"projectKeyOrId"`
+ BoardType string `json:"board-type,omitempty" api:"type"`
+ Name string `json:"name,omitempty" api:"name"`
+ StartAt int `json:"start-at,omitempty" api:"startAt"`
+ MaxResults int `json:"max-results,omitempty" api:"maxResults"`
+}
+type ListBoardsResp struct {
+ Values []Board `json:"values"`
+ StartAt int `json:"startAt"`
+ MaxResults int `json:"maxResults"`
+ Total int `json:"total"`
+ IsLast bool `json:"isLast"`
+}
+
+type ListBoardsOutput struct {
+ Boards []Board `json:"boards"`
+ StartAt int `json:"start-at"`
+ MaxResults int `json:"max-results"`
+ Total int `json:"total"`
+ IsLast bool `json:"is-last"`
+}
+
+func (jiraClient *Client) listBoardsTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) {
+ var opt ListBoardsInput
+ if err := base.ConvertFromStructpb(props, &opt); err != nil {
+ return nil, err
+ }
+
+ boards, err := jiraClient.listBoards(ctx, &opt)
+ if err != nil {
+ return nil, err
+ }
+ var output ListBoardsOutput
+ output.Boards = append(output.Boards, boards.Values...)
+ if output.Boards == nil {
+ output.Boards = []Board{}
+ }
+ output.StartAt = boards.StartAt
+ output.MaxResults = boards.MaxResults
+ output.IsLast = boards.IsLast
+ output.Total = boards.Total
+ return base.ConvertToStructpb(output)
+}
+
+func (jiraClient *Client) listBoards(_ context.Context, opt *ListBoardsInput) (*ListBoardsResp, error) {
+ apiEndpoint := "rest/agile/1.0/board"
+
+ req := jiraClient.Client.R().SetResult(&ListBoardsResp{})
+ err := addQueryOptions(req, *opt)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := req.Get(apiEndpoint)
+
+ if err != nil {
+ return nil, err
+ }
+ boards := resp.Result().(*ListBoardsResp)
+ return boards, err
+}
+
+type GetBoardResp struct {
+ Location struct {
+ DisplayName string `json:"displayName"`
+ Name string `json:"name"`
+ ProjectKey string `json:"projectKey"`
+ ProjectID int `json:"projectId"`
+ ProjectName string `json:"projectName"`
+ ProjectTypeKey string `json:"projectTypeKey"`
+ UserAccountID string `json:"userAccountId"`
+ UserID string `json:"userId"`
+ } `json:"location"`
+ Board
+}
+
+func (jiraClient *Client) getBoard(_ context.Context, boardID int) (*GetBoardResp, error) {
+ apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%v", boardID)
+
+ req := jiraClient.Client.R().SetResult(&GetBoardResp{})
+ resp, err := req.Get(apiEndpoint)
+ if err != nil {
+ return nil, fmt.Errorf(
+ err.Error(), errmsg.Message(err),
+ )
+ }
+ result := resp.Result().(*GetBoardResp)
+
+ return result, err
+}
diff --git a/application/jira/v0/client.go b/application/jira/v0/client.go
new file mode 100644
index 00000000..6da3bde5
--- /dev/null
+++ b/application/jira/v0/client.go
@@ -0,0 +1,161 @@
+package jira
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "strings"
+
+ "github.com/go-resty/resty/v2"
+ "github.com/instill-ai/component/base"
+ "github.com/instill-ai/component/internal/util/httpclient"
+ "github.com/instill-ai/x/errmsg"
+ "google.golang.org/protobuf/types/known/structpb"
+)
+
+type Client struct {
+ *httpclient.Client
+ APIBaseURL string `json:"api-base-url"`
+ Domain string `json:"domain"`
+ CloudID string `json:"cloud-id"`
+}
+
+type CloudID struct {
+ ID string `json:"cloudId"`
+}
+
+type AuthConfig struct {
+ Email string `json:"email"`
+ Token string `json:"token"`
+ BaseURL string `json:"base-url"`
+}
+
+func newClient(_ context.Context, setup *structpb.Struct) (*Client, error) {
+ var authConfig AuthConfig
+ if err := base.ConvertFromStructpb(setup, &authConfig); err != nil {
+ return nil, err
+ }
+
+ email := authConfig.Email
+ token := authConfig.Token
+ baseURL := authConfig.BaseURL
+ if token == "" {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("token not provided"),
+ "token not provided",
+ )
+ }
+ if email == "" {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("email not provided"),
+ "email not provided",
+ )
+ }
+ cloudID, err := getCloudID(baseURL)
+ if err != nil {
+ return nil, err
+ }
+
+ jiraClient := httpclient.New(
+ "Jira-Client",
+ baseURL,
+ httpclient.WithEndUserError(new(errBody)),
+ )
+ jiraClient.
+ SetHeader("Accept", "application/json").
+ SetHeader("Content-Type", "application/json").
+ SetBasicAuth(email, token)
+ client := &Client{
+ Client: jiraClient,
+ APIBaseURL: apiBaseURL,
+ Domain: baseURL,
+ CloudID: cloudID,
+ }
+ return client, nil
+}
+
+func getCloudID(baseURL string) (string, error) {
+ client := httpclient.New("Get-Domain-ID", baseURL, httpclient.WithEndUserError(new(errBody)))
+ resp := CloudID{}
+ req := client.R().SetResult(&resp)
+ // See https://developer.atlassian.com/cloud/jira/software/rest/intro/#base-url-differences
+ if _, err := req.Get("_edge/tenant_info"); err != nil {
+ return "", err
+ }
+ return resp.ID, nil
+}
+
+type errBody struct {
+ Body struct {
+ Msg []string `json:"errorMessages"`
+ } `json:"body"`
+}
+
+func (e errBody) Message() string {
+ return strings.Join(e.Body.Msg, " ")
+}
+
+func addQueryOptions(req *resty.Request, opt interface{}) error {
+ var debug DebugSession
+ debug.SessionStart("addQueryOptions", StaticVerboseLevel)
+ defer debug.SessionEnd()
+
+ v := reflect.ValueOf(opt)
+ if v.Kind() == reflect.Ptr && v.IsNil() {
+ return nil
+ }
+ if v.Kind() == reflect.Map {
+ for _, key := range v.MapKeys() {
+ if v.MapIndex(key).IsValid() && v.MapIndex(key).CanInterface() {
+ val := v.MapIndex(key).Interface()
+ var stringVal string
+ switch val := val.(type) {
+ case string:
+ stringVal = val
+ case int:
+ stringVal = fmt.Sprintf("%d", val)
+ case bool:
+ stringVal = fmt.Sprintf("%t", val)
+ default:
+ continue
+ }
+ if stringVal == fmt.Sprintf("%v", reflect.Zero(reflect.TypeOf(val))) {
+ debug.AddMessage(key.String(), "Query value is not set. Skipping.")
+ continue
+ }
+ paramName := key.String()
+ req.SetQueryParam(paramName, stringVal)
+ }
+ }
+ } else if v.Kind() == reflect.Struct {
+ typeOfS := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ if !v.Field(i).IsValid() || !v.Field(i).CanInterface() {
+ debug.AddMessage(typeOfS.Field(i).Name, "Not a valid field")
+ continue
+ }
+ val := v.Field(i).Interface()
+ var stringVal string
+ switch val := val.(type) {
+ case string:
+ stringVal = val
+ case int:
+ stringVal = fmt.Sprintf("%d", val)
+ case bool:
+ stringVal = fmt.Sprintf("%t", val)
+ default:
+ continue
+ }
+ if stringVal == fmt.Sprintf("%v", reflect.Zero(reflect.TypeOf(val))) {
+ debug.AddMessage(typeOfS.Field(i).Name, "Query value is not set. Skipping.")
+ continue
+ }
+ paramName := typeOfS.Field(i).Tag.Get("api")
+ if paramName == "" {
+ paramName = typeOfS.Field(i).Name
+ }
+ req.SetQueryParam(paramName, stringVal)
+ }
+ }
+ return nil
+}
diff --git a/application/jira/v0/component_test.go b/application/jira/v0/component_test.go
new file mode 100644
index 00000000..4b1a97c1
--- /dev/null
+++ b/application/jira/v0/component_test.go
@@ -0,0 +1,574 @@
+package jira
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/instill-ai/component/base"
+ "go.uber.org/zap"
+ "google.golang.org/protobuf/types/known/structpb"
+)
+
+const (
+ email = "testemail@gmail.com"
+ token = "testToken"
+)
+
+type TaskCase[inType any, outType any] struct {
+ _type string
+ name string
+ input inType
+ wantResp outType
+ wantErr string
+}
+
+func TestComponent_ListBoardsTask(t *testing.T) {
+ testcases := []TaskCase[ListBoardsInput, ListBoardsOutput]{
+ {
+ _type: "ok",
+ name: "get all boards",
+ input: ListBoardsInput{
+ MaxResults: 10,
+ StartAt: 0,
+ },
+ wantResp: ListBoardsOutput{
+ Total: 1,
+ StartAt: 0,
+ MaxResults: 10,
+ IsLast: true,
+ Boards: []Board{
+ {
+ ID: 3,
+ Name: "TST",
+ BoardType: "simple",
+ Self: "https://test.atlassian.net/rest/agile/1.0/board/3",
+ },
+ },
+ },
+ },
+ {
+ _type: "ok",
+ name: "get filtered boards",
+ input: ListBoardsInput{
+ MaxResults: 10,
+ StartAt: 1,
+ BoardType: "kanban",
+ },
+ wantResp: ListBoardsOutput{
+ Total: 1,
+ StartAt: 1,
+ MaxResults: 10,
+ IsLast: true,
+ Boards: []Board{},
+ },
+ },
+ {
+ _type: "nok",
+ name: "400 - Not Found",
+ input: ListBoardsInput{
+ MaxResults: 10,
+ StartAt: 1,
+ ProjectKeyOrID: "test",
+ },
+ wantErr: "unsuccessful HTTP response.*",
+ },
+ }
+ taskTesting(testcases, taskListBoards, t)
+}
+
+func TestComponent_GetIssueTask(t *testing.T) {
+ testcases := []TaskCase[GetIssueInput, GetIssueOutput]{
+ {
+ _type: "ok",
+ name: "get issue-Task",
+ input: GetIssueInput{
+ IssueKey: "TST-1",
+ UpdateHistory: true,
+ },
+ wantResp: GetIssueOutput{
+ Issue: Issue{
+ ID: "1",
+ Key: "TST-1",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 1",
+ "description": "Test description 1",
+ "status": map[string]interface{}{
+ "name": "To Do",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Task",
+ },
+ },
+ Self: "https://test.atlassian.net/rest/agile/1.0/issue/1",
+ Summary: "Test issue 1",
+ Status: "To Do",
+ Description: "Test description 1",
+ IssueType: "Task",
+ },
+ },
+ },
+ {
+ _type: "ok",
+ name: "get issue-Epic",
+ input: GetIssueInput{
+ IssueKey: "KAN-4",
+ UpdateHistory: false,
+ },
+ wantResp: GetIssueOutput{
+ Issue: Issue{
+ ID: "4",
+ Key: "KAN-4",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 4",
+ "status": map[string]interface{}{
+ "name": "Done",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Epic",
+ },
+ },
+ Self: "https://test.atlassian.net/rest/agile/1.0/issue/4",
+ Summary: "Test issue 4",
+ Status: "Done",
+ IssueType: "Epic",
+ },
+ },
+ },
+ {
+ _type: "nok",
+ name: "404 - Not Found",
+ input: GetIssueInput{
+ IssueKey: "5",
+ UpdateHistory: true,
+ },
+ wantErr: "unsuccessful HTTP response.*",
+ },
+ }
+ taskTesting(testcases, taskGetIssue, t)
+}
+
+func TestComponent_GetSprintTask(t *testing.T) {
+ testcases := []TaskCase[GetSprintInput, GetSprintOutput]{
+ {
+ _type: "ok",
+ name: "get sprint",
+ input: GetSprintInput{
+ SprintID: 1,
+ },
+ wantResp: GetSprintOutput{
+ ID: 1,
+ Self: "https://test.atlassian.net/rest/agile/1.0/sprint/1",
+ State: "active",
+ Name: "Sprint 1",
+ StartDate: "2021-01-01T00:00:00.000Z",
+ EndDate: "2021-01-15T00:00:00.000Z",
+ CompleteDate: "2021-01-15T00:00:00.000Z",
+ OriginBoardID: 1,
+ Goal: "Sprint goal",
+ },
+ },
+ {
+ _type: "nok",
+ name: "400 - Bad Request",
+ input: GetSprintInput{
+ SprintID: -1,
+ },
+ wantErr: "unsuccessful HTTP response.*",
+ },
+ {
+ _type: "nok",
+ name: "404 - Not Found",
+ input: GetSprintInput{
+ SprintID: 2,
+ },
+ wantErr: "unsuccessful HTTP response.*",
+ },
+ }
+ taskTesting(testcases, taskGetSprint, t)
+}
+
+func TestComponent_ListIssuesTask(t *testing.T) {
+ testcases := []TaskCase[ListIssuesInput, ListIssuesOutput]{
+ {
+ _type: "ok",
+ name: "All",
+ input: ListIssuesInput{
+ BoardName: "KAN",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "All",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 1,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{
+ {
+ ID: "4",
+ Key: "KAN-4",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 4",
+ "status": map[string]interface{}{
+ "name": "Done",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Epic",
+ },
+ },
+ IssueType: "Epic",
+ Self: "https://test.atlassian.net/rest/agile/1.0/issue/4",
+ Status: "Done",
+ Summary: "Test issue 4",
+ },
+ },
+ },
+ },
+ {
+ _type: "ok",
+ name: "Epics only",
+ input: ListIssuesInput{
+ BoardName: "KAN",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "Epics only",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 1,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{
+ {
+ ID: "4",
+ Key: "KAN-4",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 4",
+ "status": map[string]interface{}{
+ "name": "Done",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Epic",
+ },
+ },
+ IssueType: "Epic",
+ Self: "https://test.atlassian.net/rest/agile/1.0/issue/4",
+ Status: "Done",
+ Summary: "Test issue 4",
+ },
+ },
+ },
+ },
+ {
+ _type: "ok",
+ name: "In backlog only",
+ input: ListIssuesInput{
+ BoardName: "KAN",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "In backlog only",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 1,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{
+ {
+ ID: "4",
+ Key: "KAN-4",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 4",
+ "status": map[string]interface{}{
+ "name": "Done",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Epic",
+ },
+ },
+ IssueType: "Epic",
+ Self: "https://test.atlassian.net/rest/agile/1.0/issue/4",
+ Status: "Done",
+ Summary: "Test issue 4",
+ },
+ },
+ },
+ },
+ {
+ _type: "ok",
+ name: "Issues without epic assigned",
+ input: ListIssuesInput{
+ BoardName: "KAN",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "Issues without epic assigned",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 1,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{
+ {
+ ID: "4",
+ Key: "KAN-4",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 4",
+ "status": map[string]interface{}{
+ "name": "Done",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Epic",
+ },
+ },
+ IssueType: "Epic",
+ Self: "https://test.atlassian.net/rest/agile/1.0/issue/4",
+ Status: "Done",
+ Summary: "Test issue 4",
+ },
+ },
+ },
+ },
+ {
+ _type: "ok",
+ name: "Issues of an epic",
+ input: ListIssuesInput{
+ BoardName: "KAN",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "Issues of an epic",
+ EpicKey: "KAN-4",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 0,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{},
+ },
+ },
+ {
+ _type: "ok",
+ name: "Issues of an epic(long query)",
+ input: ListIssuesInput{
+ BoardName: "KAN",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "Issues of an epic",
+ EpicKey: "KAN-4" + strings.Repeat("-0", 100),
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 0,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{},
+ },
+ },
+ {
+ _type: "ok",
+ name: "Issues of a sprint",
+ input: ListIssuesInput{
+ BoardName: "KAN",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "Issues of a sprint",
+ SprintName: "KAN Sprint 1",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 0,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{},
+ },
+ },
+ {
+ _type: "ok",
+ name: "Standard Issues",
+ input: ListIssuesInput{
+ BoardName: "TST",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "Standard Issues",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 0,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{},
+ },
+ },
+ {
+ _type: "ok",
+ name: "JQL",
+ input: ListIssuesInput{
+ BoardName: "TST",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "JQL query",
+ JQL: "project = TST",
+ },
+ },
+ wantResp: ListIssuesOutput{
+ Total: 0,
+ StartAt: 0,
+ MaxResults: 10,
+ Issues: []Issue{},
+ },
+ },
+ {
+ _type: "nok",
+ name: "invalid range",
+ input: ListIssuesInput{
+ BoardName: "TST",
+ MaxResults: 10,
+ StartAt: 0,
+ Range: Range{
+ Range: "invalid",
+ },
+ },
+ wantErr: "invalid range",
+ },
+ }
+ taskTesting(testcases, taskListIssues, t)
+}
+
+func TestComponent_ListSprintsTask(t *testing.T) {
+ testcases := []TaskCase[ListSprintInput, ListSprintsOutput]{
+ {
+ _type: "ok",
+ name: "get all sprints",
+ input: ListSprintInput{
+ BoardID: 1,
+ StartAt: 0,
+ MaxResults: 10,
+ },
+ wantResp: ListSprintsOutput{
+ Total: 1,
+ StartAt: 0,
+ MaxResults: 10,
+ Sprints: []*GetSprintOutput{
+ {
+ ID: 1,
+ Self: "https://test.atlassian.net/rest/agile/1.0/sprint/1",
+ State: "active",
+ Name: "Sprint 1",
+ StartDate: "2021-01-01T00:00:00.000Z",
+ EndDate: "2021-01-15T00:00:00.000Z",
+ CompleteDate: "2021-01-15T00:00:00.000Z",
+ OriginBoardID: 1,
+ Goal: "Sprint goal",
+ },
+ },
+ },
+ },
+ {
+ _type: "nok",
+ name: "400 - Bad Request",
+ input: ListSprintInput{
+ BoardID: -1,
+ StartAt: 0,
+ MaxResults: 10,
+ },
+ wantErr: "unsuccessful HTTP response.*",
+ },
+ }
+ taskTesting(testcases, taskListSprints, t)
+}
+
+func TestAuth_nok(t *testing.T) {
+ c := qt.New(t)
+ bc := base.Component{Logger: zap.NewNop()}
+ connector := Init(bc)
+ c.Run("nok-empty token", func(c *qt.C) {
+ setup, err := structpb.NewStruct(map[string]any{
+ "token": "",
+ "email": email,
+ "base-url": "url",
+ })
+ c.Assert(err, qt.IsNil)
+ _, err = connector.CreateExecution(nil, setup, "invalid")
+ c.Assert(err, qt.ErrorMatches, "token not provided")
+ })
+ c.Run("nok-empty email", func(c *qt.C) {
+ setup, err := structpb.NewStruct(map[string]any{
+ "token": token,
+ "email": "",
+ "base-url": "url",
+ })
+ c.Assert(err, qt.IsNil)
+ _, err = connector.CreateExecution(nil, setup, "invalid")
+ c.Assert(err, qt.ErrorMatches, "email not provided")
+ })
+}
+
+func taskTesting[inType any, outType any](testcases []TaskCase[inType, outType], task string, t *testing.T) {
+ c := qt.New(t)
+ ctx := context.Background()
+ bc := base.Component{Logger: zap.NewNop()}
+ connector := Init(bc)
+
+ for _, tc := range testcases {
+ c.Run(tc._type+`-`+tc.name, func(c *qt.C) {
+ authenticationMiddleware := func(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/_edge/tenant_info" {
+ auth := base64.StdEncoding.EncodeToString([]byte(email + ":" + token))
+ c.Check(r.Header.Get("Authorization"), qt.Equals, "Basic "+auth)
+ }
+ next.ServeHTTP(w, r)
+ }
+ return http.HandlerFunc(fn)
+ }
+ setContentTypeMiddleware := func(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ next.ServeHTTP(w, r)
+ }
+ return http.HandlerFunc(fn)
+ }
+ srv := httptest.NewServer(router(authenticationMiddleware, setContentTypeMiddleware))
+ c.Cleanup(srv.Close)
+
+ setup, err := structpb.NewStruct(map[string]any{
+ "token": token,
+ "email": email,
+ "base-url": srv.URL,
+ })
+ c.Assert(err, qt.IsNil)
+
+ exec, err := connector.CreateExecution(nil, setup, task)
+ c.Assert(err, qt.IsNil)
+ pbIn, err := base.ConvertToStructpb(tc.input)
+ c.Assert(err, qt.IsNil)
+
+ got, err := exec.Execution.Execute(ctx, []*structpb.Struct{pbIn})
+ if tc.wantErr != "" {
+ c.Assert(err, qt.ErrorMatches, tc.wantErr)
+ return
+ }
+ c.Assert(err, qt.IsNil)
+ wantJSON, err := json.Marshal(tc.wantResp)
+ c.Assert(err, qt.IsNil)
+ c.Assert(got, qt.HasLen, 1)
+ c.Check(wantJSON, qt.JSONEquals, got[0].AsMap())
+ })
+ }
+}
diff --git a/application/jira/v0/config/definition.json b/application/jira/v0/config/definition.json
new file mode 100644
index 00000000..caf07d46
--- /dev/null
+++ b/application/jira/v0/config/definition.json
@@ -0,0 +1,21 @@
+{
+ "availableTasks": [
+ "TASK_LIST_BOARDS",
+ "TASK_LIST_ISSUES",
+ "TASK_LIST_SPRINTS",
+ "TASK_GET_ISSUE",
+ "TASK_GET_SPRINT"
+ ],
+ "documentationUrl": "https://www.instill.tech/docs/component/application/jira",
+ "icon": "assets/Jira.svg",
+ "id": "jira",
+ "public": true,
+ "title": "Jira",
+ "description": "Do anything available on Jira",
+ "tombstone": false,
+ "type": "COMPONENT_TYPE_APPLICATION",
+ "uid": "3b27f50d-a754-4b9d-8141-95aaec647cc5",
+ "version": "0.1.0",
+ "sourceUrl": "https://github.com/instill-ai/component/blob/main/application/jira/v0",
+ "releaseStage": "RELEASE_STAGE_ALPHA"
+}
diff --git a/application/jira/v0/config/setup.json b/application/jira/v0/config/setup.json
new file mode 100644
index 00000000..e9b18f77
--- /dev/null
+++ b/application/jira/v0/config/setup.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "properties": {
+ "token": {
+ "description": "Fill in your Jira API token. You can generate one from your Jira account \"settings > security > API tokens\".",
+ "instillUpstreamTypes": [
+ "reference"
+ ],
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillSecret": true,
+ "instillUIOrder": 0,
+ "title": "Token",
+ "type": "string"
+ },
+ "email": {
+ "description": "Fill in your Jira email address.",
+ "instillUpstreamTypes": [
+ "value",
+ "reference"
+ ],
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUIOrder": 1,
+ "title": "Email",
+ "type": "string"
+ },
+ "base-url": {
+ "description": "Fill in your Jira base URL. For example, if your Jira URL is \"https://mycompany.atlassian.net/...\", then your base URL is https://mycompany.atlassian.net.",
+ "instillUpstreamTypes": [
+ "value",
+ "reference"
+ ],
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUIOrder": 1,
+ "title": "Base URL",
+ "type": "string"
+ }
+ },
+ "required": [
+ "token",
+ "email",
+ "base-url"
+ ],
+ "instillEditOnNodeFields": [
+ "token",
+ "email",
+ "base-url"
+ ],
+ "title": "Jira Connection",
+ "type": "object"
+}
diff --git a/application/jira/v0/config/tasks.json b/application/jira/v0/config/tasks.json
new file mode 100644
index 00000000..e2239ea1
--- /dev/null
+++ b/application/jira/v0/config/tasks.json
@@ -0,0 +1,808 @@
+{
+ "$defs": {
+ "common-query-params": {
+ "start-at": {
+ "default": 0,
+ "description": "The starting index of the returned boards. Base index: 0. Default is 0",
+ "instillUIOrder": 3,
+ "title": "Start At",
+ "instillFormat": "integer",
+ "instillAcceptFormats": [
+ "integer"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "integer"
+ },
+ "max-results": {
+ "default": 50,
+ "description": "The maximum number of boards to return. Default is 50",
+ "instillUIOrder": 4,
+ "title": "Max Results",
+ "instillFormat": "integer",
+ "instillAcceptFormats": [
+ "integer"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "integer"
+ },
+ "update-history": {
+ "description": "Whether the action taken is added to the user's Recent history, as shown under `Your Work` in Jira.",
+ "title": "Update History",
+ "instillUIOrder": 5,
+ "instillFormat": "boolean",
+ "instillAcceptFormats": [
+ "boolean"
+ ],
+ "instillUpstreamTypes": [
+ "value"
+ ],
+ "type": "boolean"
+ }
+ },
+ "issue": {
+ "properties": {
+ "id": {
+ "description": "The ID of the issue",
+ "instillUIOrder": 0,
+ "title": "ID",
+ "instillFormat": "string",
+ "type": "string"
+ },
+ "key": {
+ "description": "The key of the issue, e.g. `JRA-1330`",
+ "instillUIOrder": 1,
+ "instillFormat": "string",
+ "title": "Key",
+ "type": "string"
+ },
+ "self": {
+ "description": "The URL of the issue",
+ "instillUIOrder": 2,
+ "instillFormat": "string",
+ "title": "Self",
+ "type": "string"
+ },
+ "fields": {
+ "description": "The fields of the issue. All navigable and Agile fields are returned",
+ "instillUIOrder": 3,
+ "instillFormat": "semi-structured/json",
+ "title": "Fields",
+ "type": "object",
+ "required": []
+ },
+ "issue-type": {
+ "description": "The type of the issue, can be: `Task`, `Epic`",
+ "instillUIOrder": 4,
+ "instillFormat": "string",
+ "title": "Issue Type",
+ "type": "string"
+ },
+ "summary": {
+ "description": "The summary of the issue",
+ "instillUIOrder": 5,
+ "instillFormat": "string",
+ "title": "Summary",
+ "type": "string"
+ },
+ "description": {
+ "description": "The description of the issue",
+ "instillUIOrder": 6,
+ "instillFormat": "string",
+ "title": "Description",
+ "type": "string"
+ },
+ "status": {
+ "description": "The status of the issue, can be: `To Do`, `In Progress`, `Done`",
+ "instillUIOrder": 7,
+ "instillFormat": "string",
+ "title": "Status",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "key",
+ "self",
+ "fields"
+ ],
+ "title": "Issue",
+ "type": "object"
+ },
+ "sprint": {
+ "properties": {
+ "id": {
+ "title": "ID",
+ "description": "The ID of the sprint",
+ "type": "integer",
+ "instillUIOrder": 0,
+ "instillFormat": "integer"
+ },
+ "self": {
+ "title": "Self",
+ "description": "The URL of the sprint",
+ "type": "string",
+ "instillUIOrder": 1,
+ "instillFormat": "string"
+ },
+ "state": {
+ "title": "State",
+ "description": "The state of the sprint, can be: `active`, `closed`, `future`",
+ "type": "string",
+ "instillUIOrder": 2,
+ "instillFormat": "string"
+ },
+ "name": {
+ "title": "Name",
+ "description": "The name of the sprint",
+ "type": "string",
+ "instillUIOrder": 3,
+ "instillFormat": "string"
+ },
+ "start-date": {
+ "title": "Start Date",
+ "description": "The start date of the sprint. In the RFC3339 format, e.g. 2018-03-05T00:00:00Z",
+ "type": "string",
+ "instillUIOrder": 4,
+ "instillFormat": "string"
+ },
+ "end-date": {
+ "title": "End Date",
+ "description": "The end date of the sprint. In the RFC3339 format, e.g. 2018-03-05T00:00:00Z",
+ "type": "string",
+ "instillUIOrder": 5,
+ "instillFormat": "string"
+ },
+ "complete-date": {
+ "title": "Complete Date",
+ "description": "The complete date of the sprint. In the RFC3339 format, e.g. 2018-03-05T00:00:00Z",
+ "type": "string",
+ "instillUIOrder": 6,
+ "instillFormat": "string"
+ },
+ "origin-board-id": {
+ "title": "Origin Board ID",
+ "description": "The ID of the origin board",
+ "type": "integer",
+ "instillUIOrder": 7,
+ "instillFormat": "integer"
+ },
+ "goal": {
+ "title": "Goal",
+ "description": "The Goal of the sprint",
+ "type": "string",
+ "instillUIOrder": 8,
+ "instillFormat": "string"
+ }
+ },
+ "required": [
+ "id",
+ "self"
+ ],
+ "title": "Sprint",
+ "type": "object"
+ }
+ },
+ "TASK_LIST_BOARDS": {
+ "instillShortDescription": "List all boards in Jira",
+ "input": {
+ "description": "List all boards in Jira",
+ "instillUIOrder": 0,
+ "instillEditOnNodeFields": [],
+ "properties": {
+ "project-key-or-id": {
+ "default": "",
+ "title": "Project Key or ID",
+ "description": "This filters results to boards that are relevant to a project. Relevance meaning that the JQL filter defined in board contains a reference to a project.",
+ "instillShortDescription": "The project key or ID, e.g. `INS`. Default is empty",
+ "instillUIOrder": 0,
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ },
+ "board-type": {
+ "default": "simple",
+ "description": "The type of board, can be: scrum, kanban, simple. Default is simple",
+ "instillUIOrder": 1,
+ "enum": [
+ "scrum",
+ "kanban",
+ "simple"
+ ],
+ "title": "Board Type",
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ },
+ "name": {
+ "default": "",
+ "description": "Name filters results to boards that match or partially match the specified name. Default is empty",
+ "instillUIOrder": 2,
+ "title": "Name",
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ },
+ "start-at": {
+ "$ref": "#/$defs/common-query-params/start-at",
+ "instillUIOrder": 3
+ },
+ "max-results": {
+ "$ref": "#/$defs/common-query-params/max-results",
+ "instillUIOrder": 4
+ }
+ },
+ "required": [],
+ "title": "Input",
+ "type": "object"
+ },
+ "output": {
+ "description": "List all boards in Jira",
+ "instillUIOrder": 0,
+ "properties": {
+ "boards": {
+ "description": "A array of boards in Jira",
+ "instillUIOrder": 1,
+ "title": "Boards",
+ "type": "array",
+ "items": {
+ "properties": {
+ "id": {
+ "description": "The ID of the board",
+ "instillUIOrder": 0,
+ "title": "ID",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "name": {
+ "description": "The name of the board",
+ "instillUIOrder": 1,
+ "title": "Name",
+ "instillFormat": "string",
+ "type": "string"
+ },
+ "type": {
+ "description": "The type of the board",
+ "instillUIOrder": 2,
+ "title": "Type",
+ "instillFormat": "string",
+ "type": "string"
+ },
+ "self": {
+ "description": "The URL of the board",
+ "instillUIOrder": 3,
+ "title": "Self",
+ "instillFormat": "string",
+ "type": "string"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "type",
+ "self"
+ ]
+ }
+ },
+ "start-at": {
+ "description": "The starting index of the returned boards. Base index: 0",
+ "instillUIOrder": 2,
+ "title": "Start At",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "max-results": {
+ "description": "The maximum number of boards",
+ "instillUIOrder": 3,
+ "title": "Max Results",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "total": {
+ "description": "The total number of boards",
+ "instillUIOrder": 4,
+ "title": "Total",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "is-last": {
+ "description": "Whether the last board is reached",
+ "instillUIOrder": 5,
+ "title": "Is Last",
+ "instillFormat": "boolean",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "start-at",
+ "max-results",
+ "total",
+ "is-last"
+ ],
+ "title": "Output",
+ "type": "object"
+ }
+ },
+ "TASK_LIST_ISSUES": {
+ "description": "List issues in Jira",
+ "instillShortDescription": "List issues in Jira",
+ "input": {
+ "description": "List issues in Jira",
+ "instillUIOrder": 0,
+ "instillEditOnNodeFields": [
+ "board-name",
+ "range"
+ ],
+ "properties": {
+ "board-name": {
+ "title": "Board Name",
+ "description": "The name of the board",
+ "instillShortDescription": "The name of the board",
+ "instillUIOrder": 0,
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ },
+ "range": {
+ "title": "Range",
+ "description": "Choose the range of issues to return. Default is `all`",
+ "instillUIOrder": 1,
+ "additionalProperties": true,
+ "instillFormat": "object",
+ "type": "object",
+ "required": [
+ "range"
+ ],
+ "oneOf": [
+ {
+ "properties": {
+ "range": {
+ "const": "All",
+ "type": "string"
+ }
+ },
+ "required": [
+ "range"
+ ],
+ "instillEditOnNodeFields": [
+ "range"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "range": {
+ "const": "Standard Issues",
+ "type": "string"
+ }
+ },
+ "required": [
+ "range"
+ ],
+ "instillEditOnNodeFields": [
+ "range"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "range": {
+ "const": "Epics only",
+ "type": "string"
+ }
+ },
+ "required": [
+ "range"
+ ],
+ "instillEditOnNodeFields": [
+ "range"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "range": {
+ "const": "In backlog only",
+ "type": "string"
+ }
+ },
+ "required": [
+ "range"
+ ],
+ "instillEditOnNodeFields": [
+ "range"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "range": {
+ "const": "Issues of an epic",
+ "type": "string"
+ },
+ "epic-key": {
+ "title": "Epic Key",
+ "description": "The Key of the epic, e.g. `JRA-1330`",
+ "instillShortDescription": "The Key of the epic",
+ "instillUIOrder": 10,
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "range",
+ "epic-key"
+ ],
+ "instillEditOnNodeFields": [
+ "range",
+ "epic-key"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "range": {
+ "const": "Issues of a sprint",
+ "type": "string"
+ },
+ "sprint-name": {
+ "title": "Sprint Name",
+ "description": "The name of the sprint",
+ "instillShortDescription": "The Name of the sprint",
+ "instillUIOrder": 10,
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "range",
+ "sprint-name"
+ ],
+ "instillEditOnNodeFields": [
+ "range",
+ "sprint-name"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "range": {
+ "const": "Issues without epic assigned",
+ "type": "string"
+ }
+ },
+ "required": [
+ "range"
+ ],
+ "instillEditOnNodeFields": [
+ "range"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ },
+ {
+ "properties": {
+ "range": {
+ "const": "JQL query",
+ "type": "string"
+ },
+ "jql": {
+ "title": "JQL",
+ "description": "The JQL query. For example, `type = \"Task\" AND status = \"Done\"`. For more information, see Advanced searching",
+ "instillShortDescription": "The JQL query",
+ "instillUIOrder": 10,
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "range",
+ "jql"
+ ],
+ "instillEditOnNodeFields": [
+ "range",
+ "jql"
+ ],
+ "instillFormat": "object",
+ "type": "object"
+ }
+ ]
+ },
+ "start-at": {
+ "$ref": "#/$defs/common-query-params/start-at",
+ "instillUIOrder": 3
+ },
+ "max-results": {
+ "$ref": "#/$defs/common-query-params/max-results",
+ "instillUIOrder": 4
+ }
+ },
+ "required": [
+ "board-name"
+ ],
+ "title": "Input",
+ "type": "object"
+ },
+ "output": {
+ "description": "Get issues in Jira",
+ "instillUIOrder": 0,
+ "properties": {
+ "issues": {
+ "description": "A array of issues in Jira",
+ "instillUIOrder": 1,
+ "title": "Issues",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/issue"
+ }
+ },
+ "start-at": {
+ "description": "The starting index of the returned boards. Base index: 0",
+ "instillUIOrder": 2,
+ "title": "Start At",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "max-results": {
+ "description": "The maximum number of boards",
+ "instillUIOrder": 3,
+ "title": "Max Results",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "total": {
+ "description": "The total number of boards",
+ "instillUIOrder": 4,
+ "title": "Total",
+ "instillFormat": "integer",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "start-at",
+ "max-results",
+ "total"
+ ],
+ "title": "Output",
+ "type": "object"
+ }
+ },
+ "TASK_LIST_SPRINTS": {
+ "description": "List sprints in Jira",
+ "instillShortDescription": "List sprints in Jira",
+ "input": {
+ "description": "List sprints in Jira",
+ "instillUIOrder": 0,
+ "instillEditOnNodeFields": [
+ "board-id"
+ ],
+ "properties": {
+ "board-id": {
+ "title": "Board ID",
+ "description": "The ID of the board",
+ "instillShortDescription": "The ID of the board",
+ "instillUIOrder": 0,
+ "instillFormat": "integer",
+ "instillAcceptFormats": [
+ "integer"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "integer"
+ },
+ "start-at": {
+ "$ref": "#/$defs/common-query-params/start-at",
+ "instillUIOrder": 1
+ },
+ "max-results": {
+ "$ref": "#/$defs/common-query-params/max-results",
+ "instillUIOrder": 2
+ }
+ },
+ "required": [
+ "board-id"
+ ],
+ "title": "Input",
+ "type": "object"
+ },
+ "output": {
+ "description": "Get sprints in Jira",
+ "instillUIOrder": 0,
+ "properties": {
+ "sprints": {
+ "description": "A array of sprints in Jira",
+ "instillUIOrder": 1,
+ "title": "Sprints",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/sprint"
+ }
+ },
+ "start-at": {
+ "description": "The starting index of the returned boards. Base index: 0",
+ "instillUIOrder": 2,
+ "title": "Start At",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "max-results": {
+ "description": "The maximum number of boards",
+ "instillUIOrder": 3,
+ "title": "Max Results",
+ "instillFormat": "integer",
+ "type": "integer"
+ },
+ "total": {
+ "description": "The total number of boards",
+ "instillUIOrder": 4,
+ "title": "Total",
+ "instillFormat": "integer",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "start-at",
+ "max-results",
+ "total"
+ ],
+ "title": "Output",
+ "type": "object"
+ }
+ },
+ "TASK_GET_ISSUE": {
+ "description": "Get an issue in Jira. The issue will only be returned if the user has permission to view it. Issues returned from this resource include Agile fields, like sprint, closedSprints, flagged, and epic.",
+ "instillShortDescription": "Get an issue in Jira",
+ "input": {
+ "description": "Get an issue in Jira",
+ "instillUIOrder": 0,
+ "instillEditOnNodeFields": [
+ "issue-key"
+ ],
+ "properties": {
+ "issue-key": {
+ "title": "Issue Key",
+ "description": "The key of the issue, e.g. `JRA-1330`",
+ "instillShortDescription": "The key of the issue",
+ "instillUIOrder": 0,
+ "instillFormat": "string",
+ "instillAcceptFormats": [
+ "string"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "string"
+ },
+ "update-history": {
+ "$ref": "#/$defs/common-query-params/update-history",
+ "instillUIOrder": 1
+ }
+ },
+ "required": [
+ "issue-key"
+ ],
+ "title": "Input",
+ "type": "object"
+ },
+ "output": {
+ "description": "Get an issue in Jira",
+ "instillUIOrder": 0,
+ "$ref": "#/$defs/issue",
+ "title": "Output",
+ "type": "object"
+ }
+ },
+ "TASK_GET_SPRINT": {
+ "description": "Get a sprint in Jira. The sprint will only be returned if the user can view the board that the sprint was created on, or view at least one of the issues in the sprint.",
+ "instillShortDescription": "Get a sprint in Jira",
+ "input": {
+ "description": "Get an sprint in Jira",
+ "instillUIOrder": 0,
+ "instillEditOnNodeFields": [
+ "sprint-id"
+ ],
+ "properties": {
+ "sprint-id": {
+ "title": "Sprint ID",
+ "description": "The ID of the sprint. The sprint will only be returned if you can view the board that the sprint was created on, or view at least one of the issues in the sprint.",
+ "instillShortDescription": "The ID of the sprint",
+ "instillUIOrder": 0,
+ "instillFormat": "integer",
+ "instillAcceptFormats": [
+ "integer"
+ ],
+ "instillUpstreamTypes": [
+ "value",
+ "reference",
+ "template"
+ ],
+ "type": "integer"
+ }
+ },
+ "required": [
+ "sprint-id"
+ ],
+ "title": "Input",
+ "type": "object"
+ },
+ "output": {
+ "description": "Get an sprint in Jira",
+ "instillUIOrder": 0,
+ "$ref": "#/$defs/sprint",
+ "required": [],
+ "title": "Output",
+ "type": "object"
+ }
+ }
+}
diff --git a/application/jira/v0/debug.go b/application/jira/v0/debug.go
new file mode 100644
index 00000000..2c710021
--- /dev/null
+++ b/application/jira/v0/debug.go
@@ -0,0 +1,187 @@
+package jira
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+)
+
+const (
+ Verbose = 1
+ DefaultVerboseLevel = 1
+ DevelopVerboseLevel = 1
+ StaticVerboseLevel = 2
+)
+
+type DebugSession struct {
+ SessionID string `json:"session-id"`
+ Title string `json:"title"`
+ Messages []string `json:"messages"`
+ halfBannerLen int
+ indentLevel int
+ maxDepth int
+ verboseLevel int
+}
+
+// Session Logger is only verbose when package verbose is greater than the verbose level specified here
+func (d *DebugSession) SessionStart(name string, verboseLevel int) {
+ d.verboseLevel = verboseLevel
+ if Verbose < d.verboseLevel {
+ return
+ }
+ defer d.flush()
+ d.SessionID = name
+ d.halfBannerLen = 20
+ halfBanner := strings.Repeat("=", d.halfBannerLen)
+ if d.Messages == nil {
+ d.Messages = []string{}
+ }
+ d.Title = fmt.Sprintf("%s %s %s", halfBanner, name, halfBanner)
+ d.Messages = append(d.Messages, d.Title)
+ d.indentLevel = 0
+ d.maxDepth = 5 * Verbose
+}
+
+func (d *DebugSession) AddMessage(msg ...string) {
+ if Verbose < d.verboseLevel {
+ return
+ }
+ defer d.flush()
+ parseMsg := strings.Join(msg, " ")
+ d.Messages = append(d.Messages,
+ fmt.Sprintf("[%s] %s%s", d.SessionID, strings.Repeat("\t", d.indentLevel), parseMsg))
+}
+
+// addMapMessage adds a map message to the debug session
+// if the map is empty, it will simply add "Map: {}"
+func (d *DebugSession) AddMapMessage(name string, m interface{}) {
+ if Verbose < d.verboseLevel {
+ return
+ }
+ defer d.flush()
+ if name == "" {
+ name = "Map"
+ }
+ d.AddMessage(name + ": {")
+ defer d.AddMessage("}")
+
+ v := reflect.ValueOf(m)
+ if v.Kind() == reflect.Ptr && v.IsNil() {
+ d.AddMessage("Not a map")
+ return
+ } else if v.Kind() == reflect.Ptr {
+ v = v.Elem()
+ }
+ mapVal := make(map[string]interface{})
+ if v.Kind() == reflect.Map {
+ for _, key := range v.MapKeys() {
+ if v.MapIndex(key).IsValid() && v.MapIndex(key).CanInterface() {
+ mapVal[fmt.Sprintf("%v", key)] = v.MapIndex(key).Interface()
+ }
+ }
+ } else if v.Kind() == reflect.Struct {
+ typeOfS := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ if !v.Field(i).IsValid() || !v.Field(i).CanInterface() {
+ continue
+ }
+ val := v.Field(i).Interface()
+ paramName := typeOfS.Field(i).Name
+ mapVal[paramName] = val
+ }
+ } else {
+ d.AddMessage("Not parseable as a map. Type: ", v.Kind().String())
+ return
+ }
+ d.addInternalMapMessage(mapVal, 0)
+}
+
+func (d *DebugSession) AddRawMessage(m interface{}) {
+ defer d.flush()
+ d.Messages = append(d.Messages,
+ fmt.Sprintf("[%s] %s%v", d.SessionID, strings.Repeat("\t", d.indentLevel), m))
+}
+
+func (d *DebugSession) addInternalMapMessage(m map[string]interface{}, depth int) {
+ defer d.Indent()()
+ if depth > d.maxDepth {
+ d.AddMessage("...")
+ return
+ }
+ for k, v := range m {
+ switch v := v.(type) {
+ case map[string]interface{}:
+ d.AddMessage(k + ": {")
+ d.addInternalMapMessage(v, depth+1)
+ d.AddMessage("}")
+ case []interface{}:
+ d.AddMessage(k + ": {")
+ d.addInternalSliceMessage(v, depth+1)
+ d.AddMessage("}")
+ default:
+ d.AddMessage(fmt.Sprintf("%s: %v", k, v))
+ }
+ }
+}
+
+func (d *DebugSession) addInternalSliceMessage(s []interface{}, depth int) {
+ defer d.Indent()()
+
+ if depth > d.maxDepth {
+ d.AddMessage("...")
+ return
+ }
+ for _, v := range s {
+ switch v := v.(type) {
+ case map[string]interface{}:
+ d.AddMessage("-")
+ d.addInternalMapMessage(v, depth+1)
+ case []interface{}:
+ d.AddMessage("-")
+ d.addInternalSliceMessage(v, depth+1)
+ default:
+ d.AddMessage(fmt.Sprintf("- %v", v))
+ }
+ }
+}
+
+func (d *DebugSession) SessionEnd() {
+ if Verbose < d.verboseLevel {
+ return
+ }
+ defer d.flush()
+ defer func() {
+ d.indentLevel = 0
+ }()
+ endHalfBanner := strings.Repeat("=", d.halfBannerLen-2)
+ endBanner := fmt.Sprintf("%s %s end %s", endHalfBanner, d.SessionID, endHalfBanner)
+ d.Messages = append(d.Messages, endBanner)
+}
+
+func (d *DebugSession) IncrementIndent() {
+ d.indentLevel++
+}
+func (d *DebugSession) DecrementIndent() {
+ d.indentLevel--
+}
+func (d *DebugSession) Indent() func() {
+ d.IncrementIndent()
+ return d.DecrementIndent
+}
+
+func (d *DebugSession) Separator() {
+ if Verbose < d.verboseLevel {
+ return
+ }
+ d.Messages = append(d.Messages, strings.Repeat("=", d.halfBannerLen*2+len(d.SessionID)+2))
+}
+
+func (d *DebugSession) flush() {
+ if Verbose < d.verboseLevel {
+ return
+ }
+ for _, msg := range d.Messages {
+ fmt.Println(msg)
+ }
+ d.Messages = []string{}
+}
diff --git a/application/jira/v0/issues.go b/application/jira/v0/issues.go
new file mode 100644
index 00000000..05cf1010
--- /dev/null
+++ b/application/jira/v0/issues.go
@@ -0,0 +1,304 @@
+package jira
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "strings"
+
+ "github.com/go-resty/resty/v2"
+ "github.com/instill-ai/component/base"
+ "github.com/instill-ai/x/errmsg"
+ "google.golang.org/protobuf/types/known/structpb"
+)
+
+type Issue struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ Description string `json:"description"`
+ Summary string `json:"summary"`
+ Fields map[string]interface{} `json:"fields"`
+ Self string `json:"self"`
+ IssueType string `json:"issue-type"`
+ Status string `json:"status"`
+}
+
+type GetIssueInput struct {
+ IssueKey string `json:"issue-key,omitempty" api:"issueIdOrKey"`
+ UpdateHistory bool `json:"update-history,omitempty" api:"updateHistory"`
+}
+type GetIssueOutput struct {
+ Issue
+}
+
+func extractIssue(issue *Issue) *Issue {
+ if issue.Description == "" && issue.Fields["description"] != nil {
+ description, ok := issue.Fields["description"].(string)
+ if ok {
+ issue.Description = description
+ }
+ }
+ if issue.Summary == "" && issue.Fields["summary"] != nil {
+ summary, ok := issue.Fields["summary"].(string)
+ if ok {
+ issue.Summary = summary
+ }
+ }
+ if issue.IssueType == "" && issue.Fields["issuetype"] != nil {
+ if issueType, ok := issue.Fields["issuetype"]; ok {
+ if issue.IssueType, ok = issueType.(map[string]interface{})["name"].(string); !ok {
+ issue.IssueType = ""
+ }
+ }
+ }
+ if issue.Status == "" && issue.Fields["status"] != nil {
+ if status, ok := issue.Fields["status"]; ok {
+ if issue.Status, ok = status.(map[string]interface{})["name"].(string); !ok {
+ issue.Status = ""
+ }
+ }
+ }
+ return issue
+}
+
+func (jiraClient *Client) getIssueTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) {
+ var debug DebugSession
+ debug.SessionStart("getIssueTask", StaticVerboseLevel)
+ defer debug.SessionEnd()
+
+ var opt GetIssueInput
+ if err := base.ConvertFromStructpb(props, &opt); err != nil {
+ return nil, err
+ }
+ debug.AddMessage(fmt.Sprintf("GetIssueInput: %+v", opt))
+
+ apiEndpoint := fmt.Sprintf("rest/agile/1.0/issue/%s", opt.IssueKey)
+ req := jiraClient.Client.R().SetResult(&Issue{})
+
+ opt.IssueKey = "" // Remove from query params
+ err := addQueryOptions(req, opt)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := req.Get(apiEndpoint)
+
+ if resp != nil && resp.StatusCode() == 404 {
+ return nil, fmt.Errorf(
+ err.Error(),
+ errmsg.Message(err)+"Please check you have the correct permissions to access this resource.",
+ )
+ }
+ if err != nil {
+ return nil, fmt.Errorf(
+ err.Error(), errmsg.Message(err),
+ )
+ }
+ debug.AddMessage("GET", apiEndpoint)
+ debug.AddMapMessage("QueryParam", resp.Request.QueryParam)
+ debug.AddMessage("Status", resp.Status())
+ debug.AddMapMessage("resp.Result()", resp.Result())
+ issue, ok := resp.Result().(*Issue)
+ if !ok {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("failed to convert response to `Get Issue` Output"),
+ fmt.Sprintf("failed to convert %v to `Get Issue` Output", resp.Result()),
+ )
+ }
+ issue = extractIssue(issue)
+ issueOutput := GetIssueOutput{Issue: *issue}
+ return base.ConvertToStructpb(issueOutput)
+}
+
+type Range struct {
+ Range string `json:"range,omitempty"`
+ EpicKey string `json:"epic-key,omitempty"`
+ SprintName string `json:"sprint-name,omitempty"`
+ JQL string `json:"jql,omitempty"`
+}
+
+type ListIssuesInput struct {
+ BoardName string `json:"board-name,omitempty" api:"boardName"`
+ MaxResults int `json:"max-results,omitempty" api:"maxResults"`
+ StartAt int `json:"start-at,omitempty" api:"startAt"`
+ Range Range `json:"range,omitempty"`
+}
+
+type ListIssuesResp struct {
+ Issues []Issue `json:"issues"`
+ StartAt int `json:"startAt"`
+ MaxResults int `json:"maxResults"`
+ Total int `json:"total"`
+}
+type ListIssuesOutput struct {
+ Issues []Issue `json:"issues"`
+ StartAt int `json:"start-at"`
+ MaxResults int `json:"max-results"`
+ Total int `json:"total"`
+}
+
+func (jiraClient *Client) listIssuesTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) {
+ var debug DebugSession
+ debug.SessionStart("listIssuesTask", DevelopVerboseLevel)
+ defer debug.SessionEnd()
+
+ debug.AddMapMessage("props", props)
+ var (
+ opt ListIssuesInput
+ jql string
+ )
+
+ if err := base.ConvertFromStructpb(props, &opt); err != nil {
+ return nil, err
+ }
+
+ boards, err := jiraClient.listBoards(ctx, &ListBoardsInput{Name: opt.BoardName})
+ if err != nil {
+ return nil, err
+ }
+ if len(boards.Values) == 0 {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("board not found"),
+ fmt.Sprintf("board with name %s not found", opt.BoardName),
+ )
+ } else if len(boards.Values) > 1 {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("multiple boards found"),
+ fmt.Sprintf("multiple boards are found with the partial name \"%s\". Please provide a more specific name", opt.BoardName),
+ )
+ }
+ debug.AddMapMessage("boards", boards)
+ board := boards.Values[0]
+
+ boardDetails, err := jiraClient.getBoard(ctx, board.ID)
+ if err != nil {
+ return nil, err
+ }
+ projectKey := boardDetails.Location.ProjectKey
+ if projectKey == "" {
+ projectKey = strings.Split(board.Name, "-")[0]
+ }
+ apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%d", board.ID)
+ switch opt.Range.Range {
+ case "All":
+ // https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-issue-get
+ apiEndpoint = apiEndpoint + "/issue"
+ case "Epics only":
+ // https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-epic-get
+ apiEndpoint = apiEndpoint + "/epic"
+ case "Issues of an epic":
+ // API not working: https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-epic-epicid-issue-get
+ // use JQL instead
+ jql = fmt.Sprintf("project=\"%s\" AND parent=\"%s\"", projectKey, opt.Range.EpicKey)
+ case "Issues of a sprint":
+ // API not working: https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-sprint-sprintid-issue-get
+ // use JQL instead
+ jql = fmt.Sprintf("project=\"%s\" AND sprint=\"%s\"", projectKey, opt.Range.SprintName)
+ case "In backlog only":
+ // https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-backlog-get
+ apiEndpoint = apiEndpoint + "/backlog"
+ case "Issues without epic assigned":
+ // https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-epic-none-issue-get
+ apiEndpoint = apiEndpoint + "/epic/none/issue"
+ case "Standard Issues":
+ // https://support.atlassian.com/jira-cloud-administration/docs/what-are-issue-types/
+ jql = fmt.Sprintf("project=\"%s\" AND issuetype not in (Epic, subtask)", projectKey)
+ case "JQL query":
+ jql = opt.Range.JQL
+ default:
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("invalid range"),
+ fmt.Sprintf("%s is an invalid range", opt.Range.Range),
+ )
+ }
+
+ var resp *resty.Response
+ if jql != "" {
+ resp, err = jiraClient.nextGenIssuesSearch(ctx, nextGenSearchRequest{
+ JQL: jql,
+ MaxResults: opt.MaxResults,
+ StartAt: opt.StartAt,
+ },
+ )
+ } else {
+ req := jiraClient.Client.R().SetResult(&ListIssuesResp{})
+ err = addQueryOptions(req, map[string]interface{}{
+ "maxResults": opt.MaxResults,
+ "startAt": opt.StartAt,
+ })
+ if err != nil {
+ return nil, err
+ }
+ resp, err = req.Get(apiEndpoint)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+ debug.AddMessage("Status", resp.Status())
+
+ issues, ok := resp.Result().(*ListIssuesResp)
+ if !ok {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("failed to convert response to `List Issue` Output"),
+ fmt.Sprintf("failed to convert %v to `List Issue` Output", resp.Result()),
+ )
+ }
+
+ if issues.Issues == nil {
+ issues.Issues = []Issue{}
+ }
+
+ output := ListIssuesOutput{
+ Issues: issues.Issues,
+ StartAt: issues.StartAt,
+ MaxResults: issues.MaxResults,
+ Total: issues.Total,
+ }
+ for idx, issue := range output.Issues {
+ output.Issues[idx] = *extractIssue(&issue)
+ if opt.Range.Range == "Epics only" {
+ output.Issues[idx].IssueType = "Epic"
+ }
+ }
+ return base.ConvertToStructpb(output)
+}
+
+// https://support.atlassian.com/jira-software-cloud/docs/jql-fields/
+type nextGenSearchRequest struct {
+ JQL string `json:"jql,omitempty" api:"jql"`
+ MaxResults int `json:"maxResults,omitempty" api:"maxResults"`
+ StartAt int `json:"startAt,omitempty" api:"startAt"`
+}
+
+// https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-get
+// https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-post
+func (jiraClient *Client) nextGenIssuesSearch(_ context.Context, opt nextGenSearchRequest) (*resty.Response, error) {
+ var debug DebugSession
+ debug.SessionStart("nextGenIssuesSearch", StaticVerboseLevel)
+ defer debug.SessionEnd()
+
+ debug.AddMessage("opt:")
+ debug.AddRawMessage(opt)
+ var err error
+ apiEndpoint := "/rest/api/2/search"
+
+ req := jiraClient.Client.R().SetResult(&ListIssuesResp{})
+ var resp *resty.Response
+ if len(opt.JQL) < 50 {
+ // 50 is an arbitrary number to determine if the JQL is too long to be a query param
+ if err := addQueryOptions(req, opt); err != nil {
+ return nil, err
+ }
+ resp, err = req.Get(apiEndpoint)
+ } else {
+ req.SetBody(opt)
+ resp, err = req.Post(apiEndpoint)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+ debug.AddMapMessage("Query", req.QueryParam)
+ debug.AddMessage("Status", resp.Status())
+ return resp, nil
+}
diff --git a/application/jira/v0/main.go b/application/jira/v0/main.go
new file mode 100644
index 00000000..6c6d6b48
--- /dev/null
+++ b/application/jira/v0/main.go
@@ -0,0 +1,303 @@
+//go:generate compogen readme ./config ./README.mdx
+package jira
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "sync"
+
+ "google.golang.org/protobuf/types/known/structpb"
+
+ "github.com/instill-ai/component/base"
+ "github.com/instill-ai/x/errmsg"
+)
+
+const (
+ apiBaseURL = "https://api.atlassian.com"
+ taskListBoards = "TASK_LIST_BOARDS"
+ taskListIssues = "TASK_LIST_ISSUES"
+ taskListSprints = "TASK_LIST_SPRINTS"
+ taskGetIssue = "TASK_GET_ISSUE"
+ taskGetSprint = "TASK_GET_SPRINT"
+)
+
+var (
+ //go:embed config/definition.json
+ definitionJSON []byte
+ //go:embed config/setup.json
+ setupJSON []byte
+ //go:embed config/tasks.json
+ tasksJSON []byte
+
+ once sync.Once
+ comp *component
+)
+
+type component struct {
+ base.Component
+}
+
+type execution struct {
+ base.ComponentExecution
+ execute func(context.Context, *structpb.Struct) (*structpb.Struct, error)
+ client Client
+}
+
+// Init returns an implementation of IConnector that interacts with Slack.
+func Init(bc base.Component) *component {
+ once.Do(func() {
+ comp = &component{Component: bc}
+ err := comp.LoadDefinition(definitionJSON, setupJSON, tasksJSON, nil)
+ if err != nil {
+ panic(err)
+ }
+ })
+
+ return comp
+}
+
+func (c *component) CreateExecution(sysVars map[string]any, setup *structpb.Struct, task string) (*base.ExecutionWrapper, error) {
+ ctx := context.Background()
+ jiraClient, err := newClient(ctx, setup)
+ if err != nil {
+ return nil, err
+ }
+ e := &execution{
+ ComponentExecution: base.ComponentExecution{Component: c, SystemVariables: sysVars, Setup: setup, Task: task},
+ client: *jiraClient,
+ }
+ // docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/#about
+ switch task {
+ case taskListBoards:
+ e.execute = e.client.listBoardsTask
+ case taskListIssues:
+ e.execute = e.client.listIssuesTask
+ case taskListSprints:
+ e.execute = e.client.listSprintsTask
+ case taskGetIssue:
+ e.execute = e.client.getIssueTask
+ case taskGetSprint:
+ e.execute = e.client.getSprintTask
+ default:
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("not supported task: %s", task),
+ fmt.Sprintf("%s task is not supported.", task),
+ )
+ }
+
+ return &base.ExecutionWrapper{Execution: e}, nil
+}
+
+func (e *execution) getInputSchemaJSON(task string) (map[string]interface{}, error) {
+ taskSpec, ok := e.Component.GetTaskInputSchemas()[task]
+ if !ok {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("task %s not found", task),
+ fmt.Sprintf("Task %s not found", task),
+ )
+ }
+ var taskSpecMap map[string]interface{}
+ err := json.Unmarshal([]byte(taskSpec), &taskSpecMap)
+ if err != nil {
+ return nil, errmsg.AddMessage(
+ err,
+ "Failed to unmarshal input",
+ )
+ }
+ inputMap := taskSpecMap["properties"].(map[string]interface{})
+ return inputMap, nil
+}
+func (e *execution) fillInDefaultValues(input *structpb.Struct) (*structpb.Struct, error) {
+ inputMap, err := e.getInputSchemaJSON(e.Task)
+ if err != nil {
+ return nil, err
+ }
+ return e.fillInDefaultValuesWithReference(input, inputMap)
+}
+func hasNextLevel(valueMap map[string]interface{}) bool {
+ if valType, ok := valueMap["type"]; ok {
+ if valType != "object" {
+ return false
+ }
+ }
+ if _, ok := valueMap["properties"]; ok {
+ return true
+ }
+ for _, target := range []string{"allOf", "anyOf", "oneOf"} {
+ if _, ok := valueMap[target]; ok {
+ items := valueMap[target].([]interface{})
+ for _, v := range items {
+ if _, ok := v.(map[string]interface{})["properties"].(map[string]interface{}); ok {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+func optionMatch(valueMap *structpb.Struct, reference map[string]interface{}, checkFields []string) bool {
+ for _, checkField := range checkFields {
+ if _, ok := valueMap.GetFields()[checkField]; !ok {
+ return false
+ }
+ if val, ok := reference[checkField].(map[string]interface{})["const"]; ok {
+ if valueMap.GetFields()[checkField].GetStringValue() != val {
+ return false
+ }
+ }
+ }
+ return true
+}
+func (e *execution) fillInDefaultValuesWithReference(input *structpb.Struct, reference map[string]interface{}) (*structpb.Struct, error) {
+ for key, value := range reference {
+ valueMap, ok := value.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if _, ok := valueMap["default"]; !ok {
+ if !hasNextLevel(valueMap) {
+ continue
+ }
+ if _, ok := input.GetFields()[key]; !ok {
+ input.GetFields()[key] = &structpb.Value{
+ Kind: &structpb.Value_StructValue{
+ StructValue: &structpb.Struct{
+ Fields: make(map[string]*structpb.Value),
+ },
+ },
+ }
+ }
+ var properties map[string]interface{}
+ if _, ok := valueMap["properties"]; !ok {
+ var requiredFieldsRaw []interface{}
+ if requiredFieldsRaw, ok = valueMap["required"].([]interface{}); !ok {
+ continue
+ }
+ requiredFields := make([]string, len(requiredFieldsRaw))
+ for idx, v := range requiredFieldsRaw {
+ requiredFields[idx] = fmt.Sprintf("%v", v)
+ }
+ for _, target := range []string{"allOf", "anyOf", "oneOf"} {
+ var items []interface{}
+ if items, ok = valueMap[target].([]interface{}); !ok {
+ continue
+ }
+ for _, v := range items {
+ if properties, ok = v.(map[string]interface{})["properties"].(map[string]interface{}); !ok {
+ continue
+ }
+ inputSubField := input.GetFields()[key].GetStructValue()
+ if target == "oneOf" && !optionMatch(inputSubField, properties, requiredFields) {
+ continue
+ }
+ subField, err := e.fillInDefaultValuesWithReference(inputSubField, properties)
+ if err != nil {
+ return nil, err
+ }
+ input.GetFields()[key] = &structpb.Value{
+ Kind: &structpb.Value_StructValue{
+ StructValue: subField,
+ },
+ }
+ }
+ }
+ } else {
+ if properties, ok = valueMap["properties"].(map[string]interface{}); !ok {
+ continue
+ }
+ subField, err := e.fillInDefaultValuesWithReference(input.GetFields()[key].GetStructValue(), properties)
+ if err != nil {
+ return nil, err
+ }
+ input.GetFields()[key] = &structpb.Value{
+ Kind: &structpb.Value_StructValue{
+ StructValue: subField,
+ },
+ }
+ }
+ continue
+ }
+ if _, ok := input.GetFields()[key]; ok {
+ continue
+ }
+ defaultValue := valueMap["default"]
+ typeValue := valueMap["type"]
+ switch typeValue {
+ case "string":
+ input.GetFields()[key] = &structpb.Value{
+ Kind: &structpb.Value_StringValue{
+ StringValue: fmt.Sprintf("%v", defaultValue),
+ },
+ }
+ case "integer", "number":
+ input.GetFields()[key] = &structpb.Value{
+ Kind: &structpb.Value_NumberValue{
+ NumberValue: defaultValue.(float64),
+ },
+ }
+ case "boolean":
+ input.GetFields()[key] = &structpb.Value{
+ Kind: &structpb.Value_BoolValue{
+ BoolValue: defaultValue.(bool),
+ },
+ }
+ case "array":
+ input.GetFields()[key] = &structpb.Value{
+ Kind: &structpb.Value_ListValue{
+ ListValue: &structpb.ListValue{
+ Values: []*structpb.Value{},
+ },
+ },
+ }
+ itemType := valueMap["items"].(map[string]interface{})["type"]
+ switch itemType {
+ case "string":
+ for _, v := range defaultValue.([]interface{}) {
+ input.GetFields()[key].GetListValue().Values = append(input.GetFields()[key].GetListValue().Values, &structpb.Value{
+ Kind: &structpb.Value_StringValue{
+ StringValue: fmt.Sprintf("%v", v),
+ },
+ })
+ }
+ case "integer", "number":
+ for _, v := range defaultValue.([]interface{}) {
+ input.GetFields()[key].GetListValue().Values = append(input.GetFields()[key].GetListValue().Values, &structpb.Value{
+ Kind: &structpb.Value_NumberValue{
+ NumberValue: v.(float64),
+ },
+ })
+ }
+ case "boolean":
+ for _, v := range defaultValue.([]interface{}) {
+ input.GetFields()[key].GetListValue().Values = append(input.GetFields()[key].GetListValue().Values, &structpb.Value{
+ Kind: &structpb.Value_BoolValue{
+ BoolValue: v.(bool),
+ },
+ })
+ }
+ }
+ }
+ }
+ return input, nil
+}
+
+func (e *execution) Execute(ctx context.Context, inputs []*structpb.Struct) ([]*structpb.Struct, error) {
+ outputs := make([]*structpb.Struct, len(inputs))
+
+ for i, input := range inputs {
+ input, err := e.fillInDefaultValues(input)
+ if err != nil {
+ return nil, err
+ }
+ output, err := e.execute(ctx, input)
+ if err != nil {
+ return nil, err
+ }
+
+ outputs[i] = output
+ }
+
+ return outputs, nil
+}
diff --git a/application/jira/v0/mock_database.go b/application/jira/v0/mock_database.go
new file mode 100644
index 00000000..34b2339c
--- /dev/null
+++ b/application/jira/v0/mock_database.go
@@ -0,0 +1,144 @@
+package jira
+
+import (
+ "fmt"
+)
+
+type FakeBoard struct {
+ Board
+}
+
+func (f *FakeBoard) getSelf() string {
+ if f.Self == "" {
+ f.Self = fmt.Sprintf("https://test.atlassian.net/rest/agile/1.0/board/%d", f.ID)
+ }
+ return f.Self
+}
+
+var fakeBoards = []FakeBoard{
+ {
+ Board: Board{
+ ID: 1,
+ Name: "KAN",
+ BoardType: "kanban",
+ },
+ },
+ {
+ Board: Board{
+ ID: 2,
+ Name: "SCR",
+ BoardType: "scrum",
+ },
+ },
+ {
+ Board: Board{
+ ID: 3,
+ Name: "TST",
+ BoardType: "simple",
+ },
+ },
+}
+
+type FakeIssue struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ Self string `json:"self"`
+ Fields map[string]interface{} `json:"fields"`
+}
+
+func (f *FakeIssue) getSelf() string {
+ if f.Self == "" {
+ f.Self = fmt.Sprintf("https://test.atlassian.net/rest/agile/1.0/issue/%s", f.ID)
+ }
+ return f.Self
+}
+
+var fakeIssues = []FakeIssue{
+ {
+ ID: "1",
+ Key: "TST-1",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 1",
+ "description": "Test description 1",
+ "status": map[string]interface{}{
+ "name": "To Do",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Task",
+ },
+ },
+ },
+ {
+ ID: "2",
+ Key: "TST-2",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 2",
+ "description": "Test description 2",
+ "status": map[string]interface{}{
+ "name": "In Progress",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Task",
+ },
+ },
+ },
+ {
+ ID: "3",
+ Key: "TST-3",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 3",
+ "description": "Test description 3",
+ "status": map[string]interface{}{
+ "name": "Done",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Task",
+ },
+ },
+ },
+ {
+ ID: "4",
+ Key: "KAN-4",
+ Fields: map[string]interface{}{
+ "summary": "Test issue 4",
+ "status": map[string]interface{}{
+ "name": "Done",
+ },
+ "issuetype": map[string]interface{}{
+ "name": "Epic",
+ },
+ },
+ },
+}
+
+type FakeSprint struct {
+ ID int `json:"id"`
+ Self string `json:"self"`
+ State string `json:"state"`
+ Name string `json:"name"`
+ StartDate string `json:"startDate"`
+ EndDate string `json:"endDate"`
+ CompleteDate string `json:"completeDate"`
+ OriginBoardID int `json:"originBoardId"`
+ Goal string `json:"goal"`
+}
+
+func (f *FakeSprint) getSelf() string {
+ if f.Self == "" {
+ f.Self = fmt.Sprintf("https://test.atlassian.net/rest/agile/1.0/sprint/%d", f.ID)
+ }
+ return f.Self
+}
+
+var fakeSprints = []FakeSprint{
+ {
+ ID: 1,
+ State: "active",
+ Name: "Sprint 1",
+ StartDate: "2021-01-01T00:00:00.000Z",
+ EndDate: "2021-01-15T00:00:00.000Z",
+ CompleteDate: "2021-01-15T00:00:00.000Z",
+ OriginBoardID: 1,
+ Goal: "Sprint goal",
+ },
+}
diff --git a/application/jira/v0/mock_server.go b/application/jira/v0/mock_server.go
new file mode 100644
index 00000000..c3eb09ce
--- /dev/null
+++ b/application/jira/v0/mock_server.go
@@ -0,0 +1,408 @@
+package jira
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+)
+
+func router(middlewares ...func(http.Handler) http.Handler) http.Handler {
+ r := chi.NewRouter()
+ r.Use(middleware.Logger)
+ for _, m := range middlewares {
+ r.Use(m)
+ }
+ r.Get("/_edge/tenant_info", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"cloudId":"12345678-1234-1234-1234-123456789012"}`))
+ })
+ r.Get("/rest/agile/1.0/issue/{issueIdOrKey:[a-zA-z0-9-]+}", mockGetIssue)
+ r.Get("/rest/agile/1.0/sprint/{sprintId}", mockGetSprint)
+ r.Get("/rest/agile/1.0/board/{boardId}/issue", mockListIssues) // list all issues
+ r.Get("/rest/agile/1.0/board/{boardId}/epic", mockListIssues) // list all epic
+ r.Get("/rest/agile/1.0/board/{boardId}/sprint", mockListSprints) // list all sprint
+ r.Get("/rest/agile/1.0/board/{boardId}/backlog", mockListIssues) // list all issues in backlog
+ r.Get("/rest/agile/1.0/board/{boardId}/epic/none/issue", mockListIssues) // list all issues without epic assigned
+ r.Get("/rest/agile/1.0/board/{boardId}", mockGetBoard)
+ r.Get("/rest/agile/1.0/board", mockListBoards)
+
+ r.Get("/rest/api/2/search", mockIssuesSearch)
+ r.Post("/rest/api/2/search", mockIssuesSearch)
+ return r
+}
+
+func mockListBoards(res http.ResponseWriter, req *http.Request) {
+ var err error
+ opt := req.URL.Query()
+ boardType := opt.Get("type")
+ startAt := opt.Get("startAt")
+ maxResults := opt.Get("maxResults")
+ name := opt.Get("name")
+ projectKeyOrID := opt.Get("projectKeyOrId")
+ // filter boards
+ var boards []FakeBoard
+ pjNotFound := projectKeyOrID != ""
+ for _, board := range fakeBoards {
+ if boardType != "" && board.BoardType != boardType {
+ continue
+ }
+ if name != "" && !strings.Contains(board.Name, name) {
+ continue
+ }
+ if projectKeyOrID != "" {
+ if !strings.EqualFold(board.Name, projectKeyOrID) {
+ continue
+ }
+ pjNotFound = false
+ }
+ boards = append(boards, board)
+ }
+ if pjNotFound {
+ res.WriteHeader(http.StatusBadRequest)
+ _, _ = res.Write([]byte(`{"errorMessages":["No project could be found with key or id"]}`))
+ return
+ }
+ // pagination
+ start, end := 0, len(boards)
+ if startAt != "" {
+ start, err = strconv.Atoi(startAt)
+ if err != nil {
+ return
+ }
+ }
+ maxResultsNum := len(boards)
+ if maxResults != "" {
+ maxResultsNum, err = strconv.Atoi(maxResults)
+ if err != nil {
+ return
+ }
+ end = start + maxResultsNum
+ if end > len(boards) {
+ end = len(boards)
+ }
+ }
+ // response
+ res.WriteHeader(http.StatusOK)
+ respText := `{"values":[`
+ if len(boards) != 0 {
+ for i, board := range boards[start:end] {
+ if i > 0 {
+ respText += ","
+ }
+ respText += fmt.Sprintf(`{"id":%d,"name":"%s","type":"%s","self":"%s"}`, board.ID, board.Name, board.BoardType, board.getSelf())
+ }
+ }
+ respText += `],`
+ respText += `"total":` + strconv.Itoa(len(boards)) + `,"startAt":` + strconv.Itoa(start) + `,"maxResults":` + strconv.Itoa(maxResultsNum) + `,"isLast":` + strconv.FormatBool(end == len(boards)) + `}`
+ _, _ = res.Write([]byte(respText))
+}
+
+func mockGetBoard(res http.ResponseWriter, req *http.Request) {
+ var err error
+ boardID := chi.URLParam(req, "boardId")
+ // filter boards
+ var board *FakeBoard
+ for _, b := range fakeBoards {
+ if boardID != "" && strconv.Itoa(b.ID) != boardID {
+ continue
+ }
+ board = &b
+ }
+ if board == nil {
+ res.WriteHeader(http.StatusNotFound)
+ _, _ = res.Write([]byte(`{"errorMessages":["Board does not exist or you do not have permission to see it"]}`))
+ return
+ }
+ // response
+ res.WriteHeader(http.StatusOK)
+ respText, err := json.Marshal(board)
+ if err != nil {
+ return
+ }
+ _, _ = res.Write([]byte(respText))
+}
+
+func mockGetIssue(res http.ResponseWriter, req *http.Request) {
+ var err error
+
+ issueID := chi.URLParam(req, "issueIdOrKey")
+ if issueID == "" {
+ res.WriteHeader(http.StatusBadRequest)
+ _, _ = res.Write([]byte(`{"errorMessages":["Issue id or key is required"]}`))
+ return
+ }
+ // find issue
+ var issue *FakeIssue
+ for _, i := range fakeIssues {
+ if i.ID == issueID || i.Key == issueID {
+ issue = &i
+ issue.getSelf()
+ break
+ }
+ }
+ if issue == nil {
+ res.WriteHeader(http.StatusNotFound)
+ _, _ = res.Write([]byte(`{"errorMessages":["Issue does not exist or you do not have permission to see it"]}`))
+ return
+ }
+ fmt.Println(issue)
+ // response
+ res.WriteHeader(http.StatusOK)
+ respText, err := json.Marshal(issue)
+ if err != nil {
+ return
+ }
+ _, _ = res.Write(respText)
+}
+
+func mockGetSprint(res http.ResponseWriter, req *http.Request) {
+ var err error
+ sprintID := chi.URLParam(req, "sprintId")
+ if sprintID == "" {
+ res.WriteHeader(http.StatusBadRequest)
+ _, _ = res.Write([]byte(`{"errorMessages":["Sprint id is required"]}`))
+ return
+ }
+ // find sprint
+ var sprint *FakeSprint
+ for _, s := range fakeSprints {
+ if strconv.Itoa(s.ID) == sprintID {
+ sprint = &s
+ sprint.getSelf()
+ break
+ }
+ }
+ if sprint == nil {
+ res.WriteHeader(http.StatusNotFound)
+ _, _ = res.Write([]byte(`{"errorMessages":["Sprint does not exist or you do not have permission to see it"]}`))
+ return
+ }
+ // response
+ res.WriteHeader(http.StatusOK)
+ respText, err := json.Marshal(sprint)
+ if err != nil {
+ return
+ }
+ _, _ = res.Write(respText)
+}
+
+type MockListIssuesResponse struct {
+ Issues []FakeIssue `json:"issues"`
+ Total int `json:"total"`
+ StartAt int `json:"startAt"`
+ MaxResults int `json:"maxResults"`
+}
+
+func mockListIssues(res http.ResponseWriter, req *http.Request) {
+ var err error
+ opt := req.URL.Query()
+ boardID := chi.URLParam(req, "boardId")
+ jql := opt.Get("jql")
+ startAt := opt.Get("startAt")
+ maxResults := opt.Get("maxResults")
+ // find board
+ var board *FakeBoard
+ for _, b := range fakeBoards {
+ if strconv.Itoa(b.ID) == boardID {
+ board = &b
+ break
+ }
+ }
+ if board == nil {
+ res.WriteHeader(http.StatusNotFound)
+ _, _ = res.Write([]byte(`{"errorMessages":["Board does not exist or you do not have permission to see it"]}`))
+ return
+ }
+ // filter issues
+ var issues []FakeIssue
+ for _, issue := range fakeIssues {
+ prefix := strings.Split(issue.Key, "-")[0]
+ if board.Name != "" && prefix != board.Name {
+ fmt.Println("prefix", prefix, "board.Name", board.Name)
+ continue
+ }
+ if jql != "" {
+ // Skip JQL filter as there is no need to implement it
+ continue
+ }
+ issue.getSelf()
+ issues = append(issues, issue)
+ }
+ // response
+ res.WriteHeader(http.StatusOK)
+ startAtNum := 0
+ if startAt != "" {
+ startAtNum, err = strconv.Atoi(startAt)
+ if err != nil {
+ return
+ }
+ }
+ maxResultsNum, err := strconv.Atoi(maxResults)
+ if err != nil {
+ return
+ }
+ resp := MockListIssuesResponse{
+ Issues: issues,
+ Total: len(issues),
+ StartAt: startAtNum,
+ MaxResults: maxResultsNum,
+ }
+ respText, err := json.Marshal(resp)
+ if err != nil {
+ return
+ }
+ _, _ = res.Write([]byte(respText))
+}
+
+type MockListSprintsResponse struct {
+ Values []FakeSprint `json:"values"`
+ StartAt int `json:"startAt"`
+ MaxResults int `json:"maxResults"`
+ Total int `json:"total"`
+}
+
+func mockListSprints(res http.ResponseWriter, req *http.Request) {
+ var err error
+ opt := req.URL.Query()
+ boardID := chi.URLParam(req, "boardId")
+ state := opt.Get("state")
+ startAt := opt.Get("startAt")
+ maxResults := opt.Get("maxResults")
+ // find board
+ var board *FakeBoard
+ for _, b := range fakeBoards {
+ if strconv.Itoa(b.ID) == boardID {
+ board = &b
+ break
+ }
+ }
+ if board == nil {
+ res.WriteHeader(http.StatusNotFound)
+ _, _ = res.Write([]byte(`{"errorMessages":["Board does not exist or you do not have permission to see it"]}`))
+ return
+ }
+ // filter sprints
+ var sprints []FakeSprint
+ for _, sprint := range fakeSprints {
+ if sprint.ID != board.ID {
+ continue
+ }
+ if state != "" && sprint.State != state {
+ continue
+ }
+ sprints = append(sprints, sprint)
+ }
+ // pagination
+ start, end := 0, len(sprints)
+ if startAt != "" {
+ start, err = strconv.Atoi(startAt)
+ if err != nil {
+ return
+ }
+ }
+ maxResultsNum := len(sprints)
+ if maxResults != "" {
+ maxResultsNum, err = strconv.Atoi(maxResults)
+ if err != nil {
+ return
+ }
+ end = start + maxResultsNum
+ if end > len(sprints) {
+ end = len(sprints)
+ }
+ }
+ // response
+ res.WriteHeader(http.StatusOK)
+
+ resp := MockListSprintsResponse{
+ Values: sprints[start:end],
+ StartAt: start,
+ MaxResults: maxResultsNum,
+ Total: len(sprints),
+ }
+ for i := range resp.Values {
+ resp.Values[i].getSelf()
+
+ }
+ respText, err := json.Marshal(resp)
+ if err != nil {
+ return
+ }
+ _, _ = res.Write([]byte(respText))
+}
+
+type MockIssuesSearchRequest struct {
+ JQL string `json:"jql"`
+ StartAt int `json:"startAt"`
+ MaxResults int `json:"maxResults"`
+}
+
+func mockIssuesSearch(res http.ResponseWriter, req *http.Request) {
+ var err error
+ var (
+ opt url.Values
+ jql string
+ startAt string
+ maxResults string
+ )
+ if req.Method == http.MethodGet {
+ opt = req.URL.Query()
+ jql = opt.Get("jql")
+ startAt = opt.Get("startAt")
+ maxResults = opt.Get("maxResults")
+ } else if req.Method == http.MethodPost {
+ body := MockIssuesSearchRequest{}
+ err = json.NewDecoder(req.Body).Decode(&body)
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+ jql = body.JQL
+ startAt = strconv.Itoa(body.StartAt)
+ maxResults = strconv.Itoa(body.MaxResults)
+ } else {
+ res.WriteHeader(http.StatusMethodNotAllowed)
+ _, _ = res.Write([]byte(`{"errorMessages":["Method not allowed"]}`))
+ return
+ }
+ // filter issues
+ var issues []FakeIssue
+ for _, issue := range fakeIssues {
+ if jql != "" {
+ // Skip JQL filter as there is no need to implement it
+ continue
+ }
+ issue.getSelf()
+ issues = append(issues, issue)
+ }
+ // response
+ res.WriteHeader(http.StatusOK)
+ startAtNum := 0
+ if startAt != "" {
+ startAtNum, err = strconv.Atoi(startAt)
+ if err != nil {
+ return
+ }
+ }
+ maxResultsNum, err := strconv.Atoi(maxResults)
+ if err != nil {
+ return
+ }
+ resp := MockListIssuesResponse{
+ Issues: issues,
+ Total: len(issues),
+ StartAt: startAtNum,
+ MaxResults: maxResultsNum,
+ }
+ respText, err := json.Marshal(resp)
+ if err != nil {
+ return
+ }
+ _, _ = res.Write([]byte(respText))
+}
diff --git a/application/jira/v0/sprint.go b/application/jira/v0/sprint.go
new file mode 100644
index 00000000..b4cf1cd0
--- /dev/null
+++ b/application/jira/v0/sprint.go
@@ -0,0 +1,153 @@
+package jira
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+
+ "github.com/instill-ai/component/base"
+ "github.com/instill-ai/x/errmsg"
+ "google.golang.org/protobuf/types/known/structpb"
+)
+
+type Sprint struct {
+ ID int `json:"id"`
+ Self string `json:"self"`
+ State string `json:"state"`
+ Name string `json:"name"`
+ StartDate string `json:"startDate"`
+ EndDate string `json:"endDate"`
+ CompleteDate string `json:"completeDate"`
+ OriginBoardID int `json:"originBoardId"`
+ Goal string `json:"goal"`
+}
+
+type GetSprintInput struct {
+ SprintID int `json:"sprint-id"`
+}
+type GetSprintOutput struct {
+ ID int `json:"id"`
+ Self string `json:"self"`
+ State string `json:"state"`
+ Name string `json:"name"`
+ StartDate string `json:"start-date"`
+ EndDate string `json:"end-date"`
+ CompleteDate string `json:"complete-date"`
+ OriginBoardID int `json:"origin-board-id"`
+ Goal string `json:"goal"`
+}
+
+func extractSprintOutput(sprint *Sprint) *GetSprintOutput {
+ return &GetSprintOutput{
+ ID: sprint.ID,
+ Self: sprint.Self,
+ State: sprint.State,
+ Name: sprint.Name,
+ StartDate: sprint.StartDate,
+ EndDate: sprint.EndDate,
+ CompleteDate: sprint.CompleteDate,
+ OriginBoardID: sprint.OriginBoardID,
+ Goal: sprint.Goal,
+ }
+}
+func (jiraClient *Client) getSprintTask(_ context.Context, props *structpb.Struct) (*structpb.Struct, error) {
+ var debug DebugSession
+ debug.SessionStart("getSprintTask", StaticVerboseLevel)
+ defer debug.SessionEnd()
+
+ var opt GetSprintInput
+ if err := base.ConvertFromStructpb(props, &opt); err != nil {
+ return nil, err
+ }
+ debug.AddMessage(fmt.Sprintf("GetSprintInput: %+v", opt))
+
+ apiEndpoint := fmt.Sprintf("rest/agile/1.0/sprint/%v", opt.SprintID)
+ req := jiraClient.Client.R().SetResult(&Sprint{})
+ resp, err := req.Get(apiEndpoint)
+
+ if err != nil {
+ return nil, fmt.Errorf(
+ err.Error(), errmsg.Message(err),
+ )
+ }
+ debug.AddMessage("GET", apiEndpoint)
+ debug.AddMapMessage("QueryParam", resp.Request.QueryParam)
+ debug.AddMessage("Status", resp.Status())
+
+ issue, ok := resp.Result().(*Sprint)
+ if !ok {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("failed to convert response to `Get Sprint` Output"),
+ fmt.Sprintf("failed to convert %v to `Get Sprint` Output", resp.Result()),
+ )
+ }
+ out := extractSprintOutput(issue)
+ return base.ConvertToStructpb(out)
+}
+
+type ListSprintInput struct {
+ BoardID int `json:"board-id"`
+ StartAt int `json:"start-at" api:"startAt"`
+ MaxResults int `json:"max-results" api:"maxResults"`
+}
+
+type ListSprintsResp struct {
+ Values []Sprint `json:"values"`
+ StartAt int `json:"startAt"`
+ MaxResults int `json:"maxResults"`
+ Total int `json:"total"`
+}
+type ListSprintsOutput struct {
+ Sprints []*GetSprintOutput `json:"sprints"`
+ StartAt int `json:"start-at"`
+ MaxResults int `json:"max-results"`
+ Total int `json:"total"`
+}
+
+func (jiraClient *Client) listSprintsTask(_ context.Context, props *structpb.Struct) (*structpb.Struct, error) {
+ var debug DebugSession
+ debug.SessionStart("listSprintsTask", StaticVerboseLevel)
+ defer debug.SessionEnd()
+
+ var opt ListSprintInput
+ if err := base.ConvertFromStructpb(props, &opt); err != nil {
+ return nil, err
+ }
+ debug.AddMapMessage("props", props)
+ debug.AddMapMessage("opt", opt)
+ apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%d/sprint", opt.BoardID)
+
+ req := jiraClient.Client.R().SetResult(&ListSprintsResp{})
+ opt.BoardID = 0
+ err := addQueryOptions(req, opt)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := req.Get(apiEndpoint)
+
+ if err != nil {
+ return nil, fmt.Errorf(
+ err.Error(), errmsg.Message(err),
+ )
+ }
+ debug.AddMessage("GET", apiEndpoint)
+ debug.AddMapMessage("QueryParam", resp.Request.QueryParam)
+ debug.AddMessage("Status", resp.Status())
+
+ issues, ok := resp.Result().(*ListSprintsResp)
+ if !ok {
+ return nil, errmsg.AddMessage(
+ fmt.Errorf("failed to convert response to `List Sprint` Output"),
+ fmt.Sprintf("failed to convert %v to `List Sprint` Output", resp.Result()),
+ )
+ }
+ var out ListSprintsOutput
+ for _, issue := range issues.Values {
+ out.Sprints = append(out.Sprints, extractSprintOutput(&issue))
+ }
+ out.StartAt = issues.StartAt
+ out.MaxResults = issues.MaxResults
+ out.Total = issues.Total
+ return base.ConvertToStructpb(out)
+}
diff --git a/go.mod b/go.mod
index 029c7cec..bcafa75b 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@ require (
github.com/frankban/quicktest v1.14.6
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gage-technologies/mistral-go v1.1.0
+ github.com/go-chi/chi/v5 v5.1.0
github.com/go-resty/resty/v2 v2.12.0
github.com/go-sql-driver/mysql v1.8.1
github.com/gocolly/colly/v2 v2.1.0
diff --git a/go.sum b/go.sum
index 71d5b631..8e6e6cfc 100644
--- a/go.sum
+++ b/go.sum
@@ -117,7 +117,8 @@ github.com/gage-technologies/mistral-go v1.1.0 h1:POv1wM9jA/9OBXGV2YdPi9Y/h09+Mj
github.com/gage-technologies/mistral-go v1.1.0/go.mod h1:tF++Xt7U975GcLlzhrjSQb8l/x+PrriO9QEdsgm9l28=
github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 h1:u8AQ9bPa9oC+8/A/jlWouakhIvkFfuxgIIRjiy8av7I=
github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573/go.mod h1:eBvb3i++NHDH4Ugo9qCvMw8t0mTSctaEa5blJbWcNxs=
-github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
diff --git a/store/store.go b/store/store.go
index bb8ad72b..47f5ece0 100644
--- a/store/store.go
+++ b/store/store.go
@@ -20,6 +20,7 @@ import (
"github.com/instill-ai/component/application/email/v0"
"github.com/instill-ai/component/application/github/v0"
"github.com/instill-ai/component/application/googlesearch/v0"
+ "github.com/instill-ai/component/application/jira/v0"
"github.com/instill-ai/component/application/hubspot/v0"
"github.com/instill-ai/component/application/numbers/v0"
@@ -140,6 +141,7 @@ func Init(
compStore.Import(website.Init(baseComp))
compStore.Import(slack.Init(baseComp))
compStore.Import(email.Init(baseComp))
+ compStore.Import(jira.Init(baseComp))
compStore.Import(ollama.Init(baseComp))
compStore.Import(hubspot.Init(baseComp))