From 46e5a8e9122d900c3010705b9a0003c7e23a7d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A5=8A=E7=AB=A3=E5=87=B1?= <85488391+YCK1130@users.noreply.github.com> Date: Thu, 11 Jul 2024 14:57:50 +0100 Subject: [PATCH] feat: add GitHub component (#177) # Because - We need a GitHub component to complete some development automation tasks # This commit ## Related Issue instill-ai/instill-core#1025 ## Todo - [X] TASK_GET_ALL_PULL_REQUESTS: function to get all prs given owner name and repository name. - [X] TASK_GET_PULL_REQUEST: function to get a specific pr given owner name, repository name, and pr number. (including file changes) - [X] TASK_GET_REVIEW_COMMENT: get review comment inside a pull request - [X] TASK_CREATE_REVIEW_COMMENT: create review comment inside a pull request - [X] TASK_GET_COMMIT: get commit messages and file changes - [x] TASK_CREATE_ISSUE: post issue - [x] TASK_GET_ALL_ISSUES: get all issues in a repo - [x] TASK_GET_ISSUE: get an issue - [x] TASK_CREATE_WEBHOOK: register webhook, https://docs.github.com/en/webhooks/webhook-events-and-payloads --- application/github/v0/README.mdx | 266 ++++ application/github/v0/assets/Github.svg | 3 + application/github/v0/client.go | 90 ++ application/github/v0/commits.go | 115 ++ application/github/v0/component_test.go | 831 +++++++++++ application/github/v0/config/definition.json | 25 + application/github/v0/config/setup.json | 23 + application/github/v0/config/tasks.json | 1270 +++++++++++++++++ application/github/v0/issue_service_test.go | 112 ++ application/github/v0/issues.go | 209 +++ application/github/v0/main.go | 172 +++ application/github/v0/pull_request.go | 182 +++ .../github/v0/pull_request_service_test.go | 147 ++ .../github/v0/repositories_service_test.go | 105 ++ application/github/v0/review_comment.go | 151 ++ application/github/v0/utils.go | 35 + application/github/v0/webhook.go | 98 ++ go.mod | 4 +- go.sum | 5 + store/store.go | 2 + 20 files changed, 3844 insertions(+), 1 deletion(-) create mode 100644 application/github/v0/README.mdx create mode 100644 application/github/v0/assets/Github.svg create mode 100644 application/github/v0/client.go create mode 100644 application/github/v0/commits.go create mode 100644 application/github/v0/component_test.go create mode 100644 application/github/v0/config/definition.json create mode 100644 application/github/v0/config/setup.json create mode 100644 application/github/v0/config/tasks.json create mode 100644 application/github/v0/issue_service_test.go create mode 100644 application/github/v0/issues.go create mode 100644 application/github/v0/main.go create mode 100644 application/github/v0/pull_request.go create mode 100644 application/github/v0/pull_request_service_test.go create mode 100644 application/github/v0/repositories_service_test.go create mode 100644 application/github/v0/review_comment.go create mode 100644 application/github/v0/utils.go create mode 100644 application/github/v0/webhook.go diff --git a/application/github/v0/README.mdx b/application/github/v0/README.mdx new file mode 100644 index 00000000..bba692a0 --- /dev/null +++ b/application/github/v0/README.mdx @@ -0,0 +1,266 @@ +--- +title: "GitHub" +lang: "en-US" +draft: false +description: "Learn about how to set up a VDP GitHub component https://github.com/instill-ai/instill-core" +--- + +The GitHub component is an application component that allows users to do anything available on GitHub. +It can carry out the following tasks: + +- [List Pull Requests](#list-pull-requests) +- [Get Pull Request](#get-pull-request) +- [Get Commit](#get-commit) +- [Get Review Comments](#get-review-comments) +- [Create Review Comment](#create-review-comment) +- [List Issues](#list-issues) +- [Get Issue](#get-issue) +- [Create Issue](#create-issue) +- [Create Webhook](#create-webhook) + + + +## Release Stage + +`Alpha` + + + +## Configuration + +The component configuration is defined and maintained [here](https://github.com/instill-ai/component/blob/main/application/github/v0/config/definition.json). + + + + +## Setup + + +| Field | Field ID | Type | Note | +| :--- | :--- | :--- | :--- | +| Token | `token` | string | Fill in your GitHub access token for advanced usages. For more information about how to create tokens, please refer to the https://github.com/settings/tokens. | + + + + +## Supported Tasks + +### List Pull Requests + +Get the list of all pull requests in a repository + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_LIST_PULL_REQUESTS` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| State | `state` | string | State of the PRs, including open, closed, all. Default is open | +| Sort | `sort` | string | Sort the PRs by created, updated, popularity, or long-running. Default is created | +| Direction | `direction` | string | Direction of the sort, including asc or desc. Default is desc | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Pull Requests | `pull_requests` | array[object] | An array of PRs | + + + + + + +### Get Pull Request + +Get a pull request from a repository, given the PR number. This will default to the latest PR if no PR number is provided + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_GET_PULL_REQUEST` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| PR Number | `pr_number` | integer | Number of the PR. `0` for the latest PR | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Pull Request | `pull_request` | object | A pull request in GitHub | + + + + + + +### Get Commit + +Get a commit from a repository, given the commit SHA + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_GET_COMMIT` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| Commit SHA (required) | `sha` | string | SHA of the commit | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Commit | `commit` | object | A commit in GitHub | + + + + + + +### Get Review Comments + +Get the review comments in a pull request + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_GET_REVIEW_COMMENTS` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| PR Number | `pr_number` | integer | Number of the PR. Default `0` is the latest PR | +| Sort | `sort` | string | Sort the comments by created, updated. Default is created | +| Direction | `direction` | string | Direction of the sort, including asc or desc. Default is desc | +| Since | `since` | string | Only comments updated at or after this time are returned. Default is 2021-01-01T00:00:00Z | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Comments | `comments` | array[object] | An array of comments | + + + + + + +### Create Review Comment + +Create a review comment in pull request. + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_CREATE_REVIEW_COMMENT` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| PR Number (required) | `pr_number` | integer | Number of the PR | +| Comment (required) | `comment` | object | The comment to be added | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Review Comment | `comment` | object | The created comment | + + + + + + +### List Issues + +Get the list of all issues in a repository + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_LIST_ISSUES` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| State | `state` | string | State of the issues, can be one of: open, closed, all. Default is open | +| Sort | `sort` | string | Sort the issues by created, updated, popularity, or long-running. Default is created | +| Direction | `direction` | string | Direction of the sort, can be one of: asc, desc. Default is desc | +| Since | `since` | string | Only issues updated at or after this time are returned. Default is 2021-01-01T00:00:00Z | +| No Pull Request | `no_pull_request` | boolean | Whether to not include pull requests in the issues. Default is false | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Issues | `issues` | array[object] | An array of issues | + + + + + + +### Get Issue + +Get an issue. + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_GET_ISSUE` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| Issue Number (required) | `issue_number` | integer | Number of the issue | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Issue | `issue` | object | An issue in GitHub | + + + + + + +### Create Issue + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_CREATE_ISSUE` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| Issue title (required) | `title` | string | Title of the issue | +| Issue body (required) | `body` | string | Body of the issue | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Issue | `issue` | object | The created issue | + + + + + + +### Create Webhook + + +| Input | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Task ID (required) | `task` | string | `TASK_CREATE_WEBHOOK` | +| Owner (required) | `owner` | string | Owner of the repository | +| Repository (required) | `repository` | string | Repository name | +| Webhook URL (required) | `hook_url` | string | URL to send the payload to | +| Events (required) | `events` | array[string] | Events to trigger the webhook. Please see https://docs.github.com/en/webhooks/webhook-events-and-payloads for more information | +| Active | `active` | boolean | Whether the webhook is active. Default is false | +| Content Type | `content_type` | string | Content type of the webhook, can be one of: json, form. Default is json | +| Hook Secret | `hook_secret` | string | If provided, the secret will be used as the key to generate the HMAC hex digest value for delivery signature headers. (see https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers) | + + + +| Output | ID | Type | Description | +| :--- | :--- | :--- | :--- | +| Webhook | `hook` | object | The created webhook | + + + + + + + diff --git a/application/github/v0/assets/Github.svg b/application/github/v0/assets/Github.svg new file mode 100644 index 00000000..30463ca1 --- /dev/null +++ b/application/github/v0/assets/Github.svg @@ -0,0 +1,3 @@ + + + diff --git a/application/github/v0/client.go b/application/github/v0/client.go new file mode 100644 index 00000000..abfac6c6 --- /dev/null +++ b/application/github/v0/client.go @@ -0,0 +1,90 @@ +package github + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/go-github/v62/github" + "github.com/instill-ai/x/errmsg" + "golang.org/x/oauth2" + "google.golang.org/protobuf/types/known/structpb" +) + +type RepoInfoInterface interface { + getOwner() (string, error) + getRepository() (string, error) +} + +type RepoInfo struct { + Owner string `json:"owner"` + Repository string `json:"repository"` +} + +func (info RepoInfo) getOwner() (string, error) { + if info.Owner == "" { + return "", errmsg.AddMessage( + fmt.Errorf("owner not provided"), + "owner not provided", + ) + } + return info.Owner, nil +} +func (info RepoInfo) getRepository() (string, error) { + if info.Repository == "" { + return "", errmsg.AddMessage( + fmt.Errorf("repository not provided"), + "repository not provided", + ) + } + return info.Repository, nil +} + +type Client struct { + *github.Client + Repositories RepositoriesService + PullRequests PullRequestService + Issues IssuesService +} + +func newClient(ctx context.Context, setup *structpb.Struct) Client { + token := getToken(setup) + + var oauth2Client *http.Client + if token != "" { + tokenSource := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + oauth2Client = oauth2.NewClient(ctx, tokenSource) + } + client := github.NewClient(oauth2Client) + githubClient := Client{ + Client: client, + Repositories: client.Repositories, + PullRequests: client.PullRequests, + Issues: client.Issues, + } + return githubClient +} + +func parseTargetRepo(info RepoInfoInterface) (string, string, error) { + owner, ownerErr := info.getOwner() + repository, RepoErr := info.getRepository() + if ownerErr != nil && RepoErr != nil { + return "", "", errmsg.AddMessage( + fmt.Errorf("owner and repository not provided"), + "owner and repository not provided", + ) + } + if ownerErr != nil { + return "", "", ownerErr + } + if RepoErr != nil { + return "", "", RepoErr + } + return owner, repository, nil +} + +func getToken(setup *structpb.Struct) string { + return setup.GetFields()["token"].GetStringValue() +} diff --git a/application/github/v0/commits.go b/application/github/v0/commits.go new file mode 100644 index 00000000..32186115 --- /dev/null +++ b/application/github/v0/commits.go @@ -0,0 +1,115 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v62/github" + "github.com/instill-ai/component/base" + "google.golang.org/protobuf/types/known/structpb" +) + +type RepositoriesService interface { + GetCommit(context.Context, string, string, string, *github.ListOptions) (*github.RepositoryCommit, *github.Response, error) + CreateHook(context.Context, string, string, *github.Hook) (*github.Hook, *github.Response, error) +} + +type Commit struct { + SHA string `json:"sha"` + Message string `json:"message"` + Stats *CommitStats `json:"stats,omitempty"` + Files []CommitFile `json:"files,omitempty"` +} +type CommitStats struct { + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Changes int `json:"changes"` +} +type CommitFile struct { + Filename string `json:"filename"` + Patch string `json:"patch"` + CommitStats +} + +func (githubClient *Client) extractCommitFile(file *github.CommitFile) CommitFile { + return CommitFile{ + Filename: file.GetFilename(), + Patch: file.GetPatch(), + CommitStats: CommitStats{ + Additions: file.GetAdditions(), + Deletions: file.GetDeletions(), + Changes: file.GetChanges(), + }, + } +} +func (githubClient *Client) extractCommitInformation(ctx context.Context, owner, repository string, originalCommit *github.RepositoryCommit, needCommitDetails bool) Commit { + if !needCommitDetails { + return Commit{ + SHA: originalCommit.GetSHA(), + Message: originalCommit.GetCommit().GetMessage(), + } + } + stats := originalCommit.GetStats() + commitFiles := originalCommit.Files + if stats == nil || commitFiles == nil { + commit, err := githubClient.getCommit(ctx, owner, repository, originalCommit.GetSHA()) + if err == nil { + // only update stats and files if there is no error + // otherwise, we will maintain the original commit information + stats = commit.GetStats() + commitFiles = commit.Files + } + } + files := make([]CommitFile, len(commitFiles)) + for idx, file := range commitFiles { + files[idx] = githubClient.extractCommitFile(file) + } + return Commit{ + SHA: originalCommit.GetSHA(), + Message: originalCommit.GetCommit().GetMessage(), + Stats: &CommitStats{ + Additions: stats.GetAdditions(), + Deletions: stats.GetDeletions(), + Changes: stats.GetTotal(), + }, + Files: files, + } +} + +func (githubClient *Client) getCommit(ctx context.Context, owner string, repository string, sha string) (*github.RepositoryCommit, error) { + commit, _, err := githubClient.Repositories.GetCommit(ctx, owner, repository, sha, nil) + return commit, err +} + +type GetCommitInput struct { + RepoInfo + SHA string `json:"sha"` +} + +type GetCommitResp struct { + Commit Commit `json:"commit"` +} + +func (githubClient *Client) getCommitTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + var inputStruct GetCommitInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + sha := inputStruct.SHA + commit, err := githubClient.getCommit(ctx, owner, repository, sha) + if err != nil { + return nil, err + } + var resp GetCommitResp + resp.Commit = githubClient.extractCommitInformation(ctx, owner, repository, commit, true) + out, err := base.ConvertToStructpb(resp) + if err != nil { + return nil, err + } + + return out, nil +} diff --git a/application/github/v0/component_test.go b/application/github/v0/component_test.go new file mode 100644 index 00000000..3c18527c --- /dev/null +++ b/application/github/v0/component_test.go @@ -0,0 +1,831 @@ +package github + +import ( + "context" + "encoding/json" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/google/go-github/v62/github" + "github.com/instill-ai/component/base" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/structpb" +) + +var MockGithubClient = &Client{ + Repositories: &MockRepositoriesService{}, + PullRequests: &MockPullRequestService{}, + Issues: &MockIssuesService{}, +} + +var fakeHost = "https://fake-github.com" + +const ( + token = "testkey" +) + +type TaskCase[inType any, outType any] struct { + _type string + name string + input inType + wantResp outType + wantErr string +} + +func TestComponent_ListPullRequestsTask(t *testing.T) { + testcases := []TaskCase[ListPullRequestsInput, ListPullRequestsResp]{ + { + _type: "ok", + name: "get all pull requests", + input: ListPullRequestsInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + }, + wantResp: ListPullRequestsResp{ + PullRequests: []PullRequest{ + { + Base: "baseSHA", + Body: "PR Body", + Commits: []Commit{ + { + Message: "This is a fake commit", + SHA: "commitSHA", + }, + }, + DiffURL: "https://fake-github.com/test_owner/test_repo/pull/1.diff", + Head: "headSHA", + ID: 1, + Number: 1, + CommentsNum: 0, + CommitsNum: 1, + ReviewCommentsNum: 2, + State: "open", + Title: "This is a fake PR", + }, + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: ListPullRequestsInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: ListPullRequestsInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + }, + wantErr: `404 Not Found`, + }, + { + _type: "nok", + name: "no PRs found", + input: ListPullRequestsInput{ + RepoInfo: RepoInfo{ + Owner: "no_pr", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + }, + wantResp: ListPullRequestsResp{ + PullRequests: []PullRequest{}, + }, + }, + } + taskTesting(testcases, taskListPRs, t) +} + +func TestComponent_GetPullRequestTask(t *testing.T) { + testcases := []TaskCase[GetPullRequestInput, GetPullRequestResp]{ + { + _type: "ok", + name: "get pull request", + input: GetPullRequestInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + PrNumber: 1, + }, + wantResp: GetPullRequestResp{ + PullRequest: PullRequest{ + Base: "baseSHA", + Body: "PR Body", + Commits: []Commit{ + { + Message: "This is a fake commit", + SHA: "commitSHA", + Stats: &CommitStats{ + Additions: 1, + Deletions: 1, + Changes: 2, + }, + Files: []CommitFile{ + { + Filename: "filename", + Patch: "patch", + CommitStats: CommitStats{ + Additions: 1, + Deletions: 1, + Changes: 2, + }, + }, + }, + }, + }, + DiffURL: "https://fake-github.com/test_owner/test_repo/pull/1.diff", + Head: "headSHA", + ID: 1, + Number: 1, + CommentsNum: 0, + CommitsNum: 1, + ReviewCommentsNum: 2, + State: "open", + Title: "This is a fake PR", + }, + }, + }, + { + _type: "ok", + name: "get latest pull request", + input: GetPullRequestInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + PrNumber: 0, + }, + wantResp: GetPullRequestResp{ + PullRequest: PullRequest{ + Base: "baseSHA", + Body: "PR Body", + Commits: []Commit{ + { + Message: "This is a fake commit", + SHA: "commitSHA", + Stats: &CommitStats{ + Additions: 1, + Deletions: 1, + Changes: 2, + }, + Files: []CommitFile{ + { + Filename: "filename", + Patch: "patch", + CommitStats: CommitStats{ + Additions: 1, + Deletions: 1, + Changes: 2, + }, + }, + }, + }, + }, + DiffURL: "https://fake-github.com/test_owner/test_repo/pull/1.diff", + Head: "headSHA", + ID: 1, + Number: 1, + CommentsNum: 0, + CommitsNum: 1, + ReviewCommentsNum: 2, + State: "open", + Title: "This is a fake PR", + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: GetPullRequestInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + PrNumber: 1, + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: GetPullRequestInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + PrNumber: 1, + }, + wantErr: `404 Not Found`, + }, + } + taskTesting(testcases, taskGetPR, t) +} + +func TestComponent_ListReviewCommentsTask(t *testing.T) { + testcases := []TaskCase[ListReviewCommentsInput, ListReviewCommentsResp]{ + { + _type: "ok", + name: "get review comments", + input: ListReviewCommentsInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + PrNumber: 1, + Sort: "created", + Direction: "asc", + Since: "2021-01-01T00:00:00Z", + }, + wantResp: ListReviewCommentsResp{ + ReviewComments: []ReviewComment{ + { + PullRequestComment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + ID: github.Int64(1), + }, + }, + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: ListReviewCommentsInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + PrNumber: 1, + Sort: "created", + Direction: "asc", + Since: "2021-01-01T00:00:00Z", + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: ListReviewCommentsInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + PrNumber: 1, + Sort: "created", + Direction: "asc", + Since: "2021-01-01T00:00:00Z", + }, + wantErr: `404 Not Found`, + }, + { + _type: "nok", + name: "invalid time format", + input: ListReviewCommentsInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + PrNumber: 1, + Sort: "created", + Direction: "asc", + Since: "2021-0100:00:00Z", + }, + wantErr: `invalid time format`, + }, + } + taskTesting(testcases, taskGetReviewComments, t) +} + +func TestComponent_CreateReviewCommentTask(t *testing.T) { + testcases := []TaskCase[CreateReviewCommentInput, CreateReviewCommentResp]{ + { + _type: "ok", + name: "create review comment", + input: CreateReviewCommentInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + PrNumber: 1, + Comment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + Line: github.Int(2), + StartLine: github.Int(1), + Side: github.String("side"), + StartSide: github.String("side"), + SubjectType: github.String("line"), + }, + }, + wantResp: CreateReviewCommentResp{ + ReviewComment: ReviewComment{ + PullRequestComment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + ID: github.Int64(1), + Line: github.Int(2), + Position: github.Int(2), + StartLine: github.Int(1), + Side: github.String("side"), + StartSide: github.String("side"), + SubjectType: github.String("line"), + }, + }, + }, + }, + { + _type: "ok", + name: "create oneline review comment", + input: CreateReviewCommentInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + PrNumber: 1, + Comment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + Line: github.Int(1), + StartLine: github.Int(1), + Side: github.String("side"), + StartSide: github.String("side"), + SubjectType: github.String("line"), + }, + }, + wantResp: CreateReviewCommentResp{ + ReviewComment: ReviewComment{ + PullRequestComment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + ID: github.Int64(1), + Line: github.Int(1), + Position: github.Int(1), + Side: github.String("side"), + StartSide: github.String("side"), + SubjectType: github.String("line"), + }, + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: CreateReviewCommentInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + PrNumber: 1, + Comment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + Line: github.Int(2), + StartLine: github.Int(1), + Side: github.String("RIGHT"), + StartSide: github.String("RIGHT"), + SubjectType: github.String("line"), + }, + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: CreateReviewCommentInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + PrNumber: 1, + Comment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + Line: github.Int(2), + StartLine: github.Int(1), + Side: github.String("RIGHT"), + StartSide: github.String("RIGHT"), + SubjectType: github.String("line"), + }, + }, + wantErr: `404 Not Found`, + }, + { + _type: "nok", + name: "422 Unprocessable Entity", + input: CreateReviewCommentInput{ + RepoInfo: RepoInfo{ + Owner: "unprocessable_entity", + Repository: "test_repo", + }, + PrNumber: 1, + Comment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + Line: github.Int(2), + StartLine: github.Int(1), + Side: github.String("RIGHT"), + StartSide: github.String("RIGHT"), + SubjectType: github.String("line"), + }, + }, + wantErr: `422 Unprocessable Entity`, + }, + { + _type: "nok", + name: "422 Unprocessable Entity", + input: CreateReviewCommentInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + PrNumber: 1, + Comment: github.PullRequestComment{ + Body: github.String("This is a fake comment"), + Line: github.Int(1), + StartLine: github.Int(2), + Side: github.String("RIGHT"), + StartSide: github.String("RIGHT"), + SubjectType: github.String("line"), + }, + }, + wantErr: `422 Unprocessable Entity`, + }, + } + taskTesting(testcases, taskCreateReviewComment, t) +} + +func TestComponent_GetCommitTask(t *testing.T) { + testcases := []TaskCase[GetCommitInput, GetCommitResp]{ + { + _type: "ok", + name: "get commit", + input: GetCommitInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + SHA: "commitSHA", + }, + wantResp: GetCommitResp{ + Commit: Commit{ + Message: "This is a fake commit", + SHA: "commitSHA", + Stats: &CommitStats{ + Additions: 1, + Deletions: 1, + Changes: 2, + }, + Files: []CommitFile{ + { + Filename: "filename", + Patch: "patch", + CommitStats: CommitStats{ + Additions: 1, + Deletions: 1, + Changes: 2, + }, + }, + }, + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: GetCommitInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + SHA: "commitSHA", + }, + wantErr: `403 API rate limit exceeded`, + }, + } + taskTesting(testcases, taskGetCommit, t) +} + +func TestComponent_ListIssuesTask(t *testing.T) { + testcases := []TaskCase[ListIssuesInput, ListIssuesResp]{ + { + _type: "ok", + name: "get all issues", + input: ListIssuesInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + Since: "2021-01-01T00:00:00Z", + NoPullRequest: true, + }, + wantResp: ListIssuesResp{ + Issues: []Issue{ + { + Number: 1, + Title: "This is a fake Issue", + Body: "Issue Body", + State: "open", + Assignee: "assignee", + Assignees: []string{"assignee1", "assignee2"}, + Labels: []string{"label1", "label2"}, + }, + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: ListIssuesInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + Since: "2021-01-01T00:00:00Z", + NoPullRequest: true, + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: ListIssuesInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + Since: "2021-01-01T00:00:00Z", + NoPullRequest: true, + }, + wantErr: `404 Not Found`, + }, + { + _type: "nok", + name: "invalid time format", + input: ListIssuesInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + State: "open", + Direction: "asc", + Sort: "created", + Since: "2021-0Z", + NoPullRequest: true, + }, + wantErr: `invalid time format`, + }, + } + taskTesting(testcases, taskListIssues, t) +} +func TestComponent_GetIssueTask(t *testing.T) { + testcases := []TaskCase[GetIssueInput, GetIssueResp]{ + { + _type: "ok", + name: "get all issues", + input: GetIssueInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + IssueNumber: 1, + }, + wantResp: GetIssueResp{ + Issue: Issue{ + Number: 1, + Title: "This is a fake Issue", + Body: "Issue Body", + State: "open", + Assignee: "assignee", + Assignees: []string{"assignee1", "assignee2"}, + Labels: []string{"label1", "label2"}, + IsPullRequest: false, + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: GetIssueInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + IssueNumber: 1, + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: GetIssueInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + IssueNumber: 1, + }, + wantErr: `404 Not Found`, + }, + } + taskTesting(testcases, taskGetIssue, t) +} +func TestComponent_CreateIssueTask(t *testing.T) { + testcases := []TaskCase[CreateIssueInput, CreateIssueResp]{ + { + _type: "ok", + name: "get all issues", + input: CreateIssueInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + Title: "This is a fake Issue", + Body: "Issue Body", + }, + wantResp: CreateIssueResp{ + Issue: Issue{ + Number: 1, + Title: "This is a fake Issue", + Body: "Issue Body", + State: "open", + IsPullRequest: false, + Assignees: []string{}, + Labels: []string{}, + Assignee: "", + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: CreateIssueInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + Title: "This is a fake Issue", + Body: "Issue Body", + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: CreateIssueInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + Title: "This is a fake Issue", + Body: "Issue Body", + }, + wantErr: `404 Not Found`, + }, + } + taskTesting(testcases, taskCreateIssue, t) +} + +func TestComponent_CreateWebHook(t *testing.T) { + testcases := []TaskCase[CreateWebHookInput, CreateWebHookResp]{ + { + _type: "ok", + name: "create webhook", + input: CreateWebHookInput{ + RepoInfo: RepoInfo{ + Owner: "test_owner", + Repository: "test_repo", + }, + Events: []string{"push"}, + Active: *github.Bool(true), + HookSecret: "hook_secret", + ContentType: "json", + }, + wantResp: CreateWebHookResp{ + HookInfo: HookInfo{ + ID: 1, + URL: "hook_url", + PingURL: "ping_url", + TestURL: "test_url", + Config: HookConfig{ + URL: "hook_url", + InsecureSSL: "0", + ContentType: "json", + }, + }, + }, + }, + { + _type: "nok", + name: "403 API rate limit exceeded", + input: CreateWebHookInput{ + RepoInfo: RepoInfo{ + Owner: "rate_limit", + Repository: "test_repo", + }, + Events: []string{"push"}, + Active: *github.Bool(true), + HookSecret: "hook_secret", + ContentType: "json", + }, + wantErr: `403 API rate limit exceeded`, + }, + { + _type: "nok", + name: "404 Not Found", + input: CreateWebHookInput{ + RepoInfo: RepoInfo{ + Owner: "not_found", + Repository: "test_repo", + }, + Events: []string{"push"}, + Active: *github.Bool(true), + HookSecret: "hook_secret", + ContentType: "json", + }, + wantErr: `404 Not Found`, + }, + } + taskTesting(testcases, taskCreateWebhook, t) +} + +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) { + + setup, err := structpb.NewStruct(map[string]any{ + "token": token, + }) + c.Assert(err, qt.IsNil) + + e := &execution{ + ComponentExecution: base.ComponentExecution{Component: connector, SystemVariables: nil, Setup: setup, Task: task}, + client: *MockGithubClient, + } + switch task { + case taskListPRs: + e.execute = e.client.listPullRequestsTask + case taskGetPR: + e.execute = e.client.getPullRequestTask + case taskGetReviewComments: + e.execute = e.client.listReviewCommentsTask + case taskCreateReviewComment: + e.execute = e.client.createReviewCommentTask + case taskGetCommit: + e.execute = e.client.getCommitTask + case taskListIssues: + e.execute = e.client.listIssuesTask + case taskGetIssue: + e.execute = e.client.getIssueTask + case taskCreateIssue: + e.execute = e.client.createIssueTask + case taskCreateWebhook: + e.execute = e.client.createWebhookTask + default: + c.Fatalf("not supported testing task: %s", task) + } + exec := &base.ExecutionWrapper{Execution: e} + + 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 + } + wantJSON, err := json.Marshal(tc.wantResp) + c.Assert(err, qt.IsNil) + c.Check(wantJSON, qt.JSONEquals, got[0].AsMap()) + }) + } +} diff --git a/application/github/v0/config/definition.json b/application/github/v0/config/definition.json new file mode 100644 index 00000000..b0d2fde5 --- /dev/null +++ b/application/github/v0/config/definition.json @@ -0,0 +1,25 @@ +{ + "availableTasks": [ + "TASK_LIST_PULL_REQUESTS", + "TASK_GET_PULL_REQUEST", + "TASK_GET_COMMIT", + "TASK_LIST_REVIEW_COMMENTS", + "TASK_CREATE_REVIEW_COMMENT", + "TASK_LIST_ISSUES", + "TASK_GET_ISSUE", + "TASK_CREATE_ISSUE", + "TASK_CREATE_WEBHOOK" + ], + "documentationUrl": "https://www.instill.tech/docs/component/application/github", + "icon": "assets/Github.svg", + "id": "github", + "public": true, + "title": "GitHub", + "description": "Do anything available on GitHub", + "tombstone": false, + "type": "COMPONENT_TYPE_APPLICATION", + "uid": "9c14438b-90fa-41fc-83bb-4a3d9b8cbba6", + "version": "0.1.0", + "sourceUrl": "https://github.com/instill-ai/component/blob/main/application/github/v0", + "releaseStage": "RELEASE_STAGE_ALPHA" +} diff --git a/application/github/v0/config/setup.json b/application/github/v0/config/setup.json new file mode 100644 index 00000000..12be9fd1 --- /dev/null +++ b/application/github/v0/config/setup.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "token": { + "description": "Fill in your GitHub access token for advanced usages. For more information about how to create tokens, please refer to the https://github.com/settings/tokens.", + "instillUpstreamTypes": [ + "reference" + ], + "instillAcceptFormats": [ + "string" + ], + "instillSecret": true, + "instillUIOrder": 0, + "title": "Token", + "type": "string" + } + }, + "required": [], + "instillEditOnNodeFields": [], + "title": "GitHub Connection", + "type": "object" +} diff --git a/application/github/v0/config/tasks.json b/application/github/v0/config/tasks.json new file mode 100644 index 00000000..e1bc6477 --- /dev/null +++ b/application/github/v0/config/tasks.json @@ -0,0 +1,1270 @@ +{ + "$defs": { + "pull-request": { + "description": "A pull request object.", + "properties": { + "id": { + "description": "id of the PR", + "instillUIOrder": 1, + "title": "PR id", + "instillFormat": "integer", + "type": "integer" + }, + "number": { + "description": "number of the PR", + "instillFormat": "integer", + "instillUIOrder": 2, + "title": "PR number", + "type": "integer" + }, + "state": { + "description": "state of the PR", + "instillFormat": "string", + "instillUIOrder": 3, + "title": "PR state", + "type": "string" + }, + "title": { + "description": "Title of the PR", + "instillFormat": "string", + "instillUIOrder": 4, + "title": "PR Title", + "type": "string" + }, + "body": { + "description": "Body of the PR", + "instillFormat": "string", + "instillUIOrder": 5, + "title": "PR body", + "type": "string" + }, + "diff-url": { + "description": "url to the diff of the PR", + "instillFormat": "string", + "instillUIOrder": 6, + "title": "PR diff url", + "type": "string" + }, + "head": { + "description": "head commit of the PR (in SHA value)", + "instillFormat": "string", + "instillUIOrder": 8, + "title": "PR head", + "type": "string" + }, + "base": { + "description": "base commit of the PR (in SHA value)", + "instillFormat": "string", + "instillUIOrder": 9, + "title": "PR base", + "type": "string" + }, + "comments-num": { + "description": "number of comments on the PR", + "instillFormat": "integer", + "instillUIOrder": 10, + "title": "Number of PR comments", + "type": "integer" + }, + "commits-num": { + "description": "number of commits in the PR", + "instillFormat": "integer", + "instillUIOrder": 11, + "title": "Number of PR commits", + "type": "integer" + }, + "review-comments-num": { + "description": "number of review comments in the PR", + "instillFormat": "integer", + "instillUIOrder": 12, + "title": "Number of PR review comments", + "type": "integer" + }, + "commits": { + "description": "commits in the PR", + "instillUIOrder": 13, + "title": "Commits", + "instillFormat": "array:object", + "type": "array", + "items": { + "$ref": "#/$defs/commit", + "required": [], + "description": "A commit in the PR" + } + } + }, + "required": [], + "title": "Pull Request", + "type": "object" + }, + "commit": { + "description": "A commit object.", + "properties": { + "sha": { + "description": "SHA of the commit", + "instillUIOrder": 1, + "title": "Commit SHA", + "instillFormat": "string", + "type": "string" + }, + "message": { + "description": "message of the commit", + "instillUIOrder": 2, + "title": "Commit message", + "instillFormat": "string", + "type": "string" + }, + "stats": { + "instillUIOrder": 3, + "$ref": "#/$defs/commitStats", + "required": [] + }, + "files": { + "description": "files in the commit", + "instillUIOrder": 4, + "title": "Files", + "instillFormat": "array:object", + "type": "array", + "items": { + "$ref": "#/$defs/commitFile", + "required": [], + "description": "A file in the commit" + } + } + }, + "required": [], + "title": "Commit", + "type": "object" + }, + "commitStats": { + "description": "stats of changes", + "instillUIOrder": 1, + "properties": { + "additions": { + "description": "number of additions in the commit", + "instillUIOrder": 1, + "title": "Additions", + "instillFormat": "integer", + "type": "integer" + }, + "deletions": { + "description": "number of deletions in the commit", + "instillUIOrder": 2, + "title": "Deletions", + "instillFormat": "integer", + "type": "integer" + }, + "changes": { + "description": "total number of changes in the commit", + "instillUIOrder": 3, + "title": "Total changes", + "instillFormat": "integer", + "type": "integer" + } + }, + "required": [], + "title": "Commit stats", + "type": "object" + }, + "commitFile": { + "description": "A commit file object.", + "properties": { + "filename": { + "description": "name of the file", + "instillUIOrder": 1, + "title": "File name", + "instillFormat": "string", + "type": "string" + }, + "$ref": "#/$defs/commitStats/properties", + "patch": { + "description": "patch of the file", + "instillUIOrder": 3, + "title": "Patch", + "instillFormat": "string", + "type": "string" + } + }, + "required": [], + "title": "Commit File", + "type": "object" + }, + "repository-info": { + "owner": { + "description": "Owner of the repository", + "instillUIMultiline": false, + "instillUIOrder": 0, + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference", + "template" + ], + "instillFormat": "string", + "title": "Owner", + "type": "string" + }, + "repository": { + "description": "Repository name", + "instillUIMultiline": false, + "instillUIOrder": 1, + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference", + "template" + ], + "instillFormat": "string", + "title": "Repository", + "type": "string" + } + }, + "review-comments": { + "description": "A review comment object.", + "properties": { + "id": { + "description": "ID of the comment", + "instillUIOrder": 1, + "title": "Comment id", + "instillFormat": "integer", + "type": "integer" + }, + "in-reply-to-id": { + "description": "ID of the comment this comment is in reply to", + "instillFormat": "integer", + "instillUIOrder": 2, + "title": "In Reply To", + "type": "integer" + }, + "commitId": { + "description": "SHA of the commit on which you want to comment", + "instillFormat": "string", + "instillUIOrder": 3, + "title": "Commit SHA", + "type": "string" + }, + "body": { + "description": "Body of the comment", + "instillFormat": "string", + "instillUIOrder": 4, + "title": "Comment body", + "type": "string" + }, + "path": { + "description": "Path of the file the comment is on", + "instillFormat": "string", + "instillUIOrder": 5, + "title": "Comment path", + "type": "string" + }, + "line": { + "instillShortDescription": "The last line of the range that your comment applies to. Your comment will be placed under this line.", + "description": "The line of the blob in the pull request diff that the comment applies to. For a multi-line comment, the last line of the range that your comment applies to.", + "instillUIOrder": 6, + "title": "Comment end line", + "instillFormat": "integer", + "type": "integer" + }, + "start-line": { + "description": "The first line in the pull request diff that your multi-line comment applies to. Only multi-line comment needs to fill this field.", + "instillUIOrder": 7, + "title": "Comment start line", + "instillFormat": "integer", + "type": "integer" + }, + "side": { + "instillShortDescription": "Side of the end line, can be one of: LEFT, RIGHT, side. Default is side", + "description": "Side of the end line, can be one of: LEFT, RIGHT, side. LEFT is the left side of the diff (deletion), RIGHT is the right side of the diff (addition), and side is the comment on the PR as a whole. Default is side", + "default": "side", + "enum": [ + "LEFT", + "RIGHT", + "side" + ], + "instillUIOrder": 8, + "title": "Comment end side", + "instillFormat": "string", + "type": "string" + }, + "start-side": { + "instillShortDescription": "Side of the start line, can be one of: LEFT, RIGHT, side. Default is side", + "description": "Side of the start line, can be one of: LEFT, RIGHT, side. LEFT is the left side of the diff (deletion), RIGHT is the right side of the diff (addition), and side is the comment on the PR as a whole. Default is side", + "default": "side", + "enum": [ + "LEFT", + "RIGHT", + "side" + ], + "instillUIOrder": 9, + "title": "Comment start side", + "instillFormat": "string", + "type": "string" + }, + "subject-type": { + "description": "Subject type of the comment, can be one of: line, file. Default is line", + "instillUIOrder": 10, + "title": "Comment type", + "instillFormat": "string", + "default": "line", + "enum": [ + "line", + "file" + ], + "type": "string" + }, + "created-at": { + "description": "Time the comment was created", + "instillUIOrder": 11, + "title": "Comment created at", + "instillFormat": "string", + "type": "string" + }, + "updated-at": { + "description": "Time the comment was updated", + "instillUIOrder": 12, + "title": "Comment updated at", + "instillFormat": "string", + "type": "string" + }, + "user": { + "description": "User who created the comment", + "instillUIOrder": 13, + "title": "User", + "instillFormat": "object", + "type": "object", + "properties": { + "id": { + "description": "ID of the user", + "instillUIOrder": 14, + "title": "User id", + "instillFormat": "integer", + "type": "integer" + }, + "url": { + "description": "URL of the user", + "instillUIOrder": 15, + "title": "User URL", + "instillFormat": "string", + "type": "string" + } + }, + "required": [] + } + }, + "title": "Review Comment", + "type": "object" + }, + "issue": { + "description": "An issue object.", + "properties": { + "number": { + "description": "Number of the issue", + "instillUIOrder": 2, + "title": "Issue number", + "instillFormat": "integer", + "type": "integer" + }, + "state": { + "description": "State of the issue", + "instillUIOrder": 3, + "title": "Issue state", + "instillFormat": "string", + "type": "string" + }, + "title": { + "description": "Title of the issue", + "instillUIOrder": 4, + "title": "Issue title", + "instillFormat": "string", + "type": "string" + }, + "body": { + "description": "Body of the issue", + "instillUIOrder": 5, + "title": "Issue body", + "instillFormat": "string", + "type": "string" + }, + "assignee": { + "description": "Assignee of the issue", + "instillUIOrder": 6, + "title": "Assignee", + "instillFormat": "string", + "type": "string" + }, + "assignees": { + "description": "Assignees of the issue", + "instillUIOrder": 7, + "title": "Assignees", + "instillFormat": "array:string", + "type": "array", + "items": { + "instillFormat": "string", + "type": "string" + } + }, + "labels": { + "description": "Labels of the issue", + "instillUIOrder": 8, + "title": "Labels", + "instillFormat": "array:string", + "type": "array", + "items": { + "instillFormat": "string", + "type": "string" + } + }, + "is-pull-request": { + "description": "Whether the issue is a pull request", + "instillUIOrder": 9, + "title": "Is Pull Request", + "instillFormat": "boolean", + "type": "boolean" + } + }, + "title": "Issue", + "type": "object" + } + }, + "TASK_LIST_PULL_REQUESTS": { + "instillShortDescription": "Get the list of all pull requests in a repository", + "description": "Get the list of all pull requests in a repository. Detailed information about each commit in a PR is omitted, please use the `Get Commit` task or the `Get Pull Request` task to get the details of a commit.", + "input": { + "description": "Please input the repository name and owner", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "state": { + "default": "open", + "title": "State", + "description": "State of the PRs, including open, closed, all. Default is open", + "enum": [ + "open", + "closed", + "all" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 10, + "type": "string" + }, + "sort": { + "default": "created", + "title": "Sort", + "description": "Sort the PRs by created, updated, popularity, or long-running. Default is created", + "enum": [ + "created", + "updated", + "popularity", + "long-running" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 11, + "type": "string" + }, + "direction": { + "default": "desc", + "title": "Direction", + "description": "Direction of the sort, including asc or desc. Default is desc", + "enum": [ + "asc", + "desc" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 12, + "type": "string" + } + }, + "required": [ + "owner", + "repository" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "All PRs in GitHub repository", + "instillUIOrder": 0, + "properties": { + "pull-requests": { + "description": "An array of PRs", + "title": "Pull Requests", + "instillUIOrder": 1, + "instillFormat": "array:object", + "type": "array", + "items": { + "$ref": "#/$defs/pull-request", + "required": [], + "description": "A pull request in GitHub" + } + } + }, + "required": [ + "pull-requests" + ], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_GET_PULL_REQUEST": { + "instillShortDescription": "Get a pull request from a repository, given the PR number. This will default to the latest PR if no PR number is provided", + "input": { + "description": "Please input the repository name and owner, and the PR number", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository", + "pr-number" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "pr-number": { + "default": 0, + "title": "PR Number", + "description": "Number of the PR. `0` for the latest PR", + "instillFormat": "integer", + "instillAcceptFormats": [ + "integer" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 4, + "type": "integer" + } + }, + "required": [ + "owner", + "repository" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "The specific pr in GitHub repository", + "instillUIOrder": 0, + "$ref": "#/$defs/pull-request", + "required": [], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_LIST_REVIEW_COMMENTS": { + "instillShortDescription": "Get the review comments in a pull request", + "description": "Get the review comments in a pull request. The comments can be on a specific line or on the PR as a whole.", + "input": { + "description": "Please input the repository name and owner, and the PR number. Set PR number as`0` to get all comments on all PRs in the repository.", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository", + "pr-number" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "pr-number": { + "default": 0, + "title": "PR Number", + "description": "Number of the PR. Default is `0`, which retrieves all comments on all PRs in the repository", + "instillFormat": "integer", + "instillAcceptFormats": [ + "integer" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 4, + "type": "integer" + }, + "sort": { + "default": "created", + "title": "Sort", + "description": "Sort the comments by created, updated. Default is created", + "enum": [ + "created", + "updated" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 11, + "type": "string" + }, + "direction": { + "default": "desc", + "title": "Direction", + "description": "Direction of the sort, including asc or desc. Default is desc", + "enum": [ + "asc", + "desc" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 12, + "type": "string" + }, + "since": { + "default": "2021-01-01T00:00:00Z", + "title": "Since", + "description": "Only comments updated at or after this time are returned. Default is 2021-01-01T00:00:00Z", + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 13, + "type": "string" + } + }, + "required": [ + "owner", + "repository" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "Comments in the PR", + "instillUIOrder": 0, + "properties": { + "comments": { + "description": "An array of comments", + "instillUIOrder": 0, + "title": "Comments", + "instillFormat": "array:object", + "type": "array", + "items": { + "$ref": "#/$defs/review-comments", + "required": [], + "instillUIOrder": 1, + "description": "Comments in the PR" + } + } + }, + "required": [ + "comments" + ], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_CREATE_REVIEW_COMMENT": { + "instillShortDescription": "Create a review comment in pull request.", + "description": "Create a review comment in a pull request. The comment can be a general comment or a review comment. The comment can be on a specific line or on the PR as a whole.", + "input": { + "description": "Please input the repository name and owner, and the PR number", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository", + "pr-number" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "pr-number": { + "title": "PR Number", + "description": "Number of the PR", + "instillFormat": "integer", + "instillAcceptFormats": [ + "integer" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 3, + "type": "integer" + }, + "comment": { + "title": "Comment", + "description": "The comment to be added", + "instillFormat": "object", + "instillUIOrder": 4, + "type": "object", + "properties": { + "commit-id": { + "$ref": "#/$defs/review-comments/properties/commitId", + "instillUIOrder": 0 + }, + "body": { + "$ref": "#/$defs/review-comments/properties/body", + "instillUIOrder": 1 + }, + "path": { + "$ref": "#/$defs/review-comments/properties/path", + "instillUIOrder": 2 + }, + "start-line": { + "$ref": "#/$defs/review-comments/properties/start-line", + "instillUIOrder": 3 + }, + "line": { + "$ref": "#/$defs/review-comments/properties/line", + "instillUIOrder": 4 + }, + "start-side": { + "$ref": "#/$defs/review-comments/properties/start-side", + "instillUIOrder": 5 + }, + "side": { + "$ref": "#/$defs/review-comments/properties/side", + "instillUIOrder": 6 + }, + "subject-type": { + "$ref": "#/$defs/review-comments/properties/subject-type", + "instillUIOrder": 7 + } + }, + "required": [ + "body", + "path", + "commit-id" + ] + } + }, + "required": [ + "owner", + "repository", + "pr-number", + "comment" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "The created comment", + "instillUIOrder": 0, + "properties": { + "$ref": "#/$defs/review-comments/properties" + }, + "required": [], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_GET_COMMIT": { + "instillShortDescription": "Get a commit from a repository, given the commit SHA", + "input": { + "description": "Please input the repository name and owner, and the commit SHA", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository", + "sha" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "sha": { + "$ref": "#/$defs/commit/properties/sha", + "instillUIOrder": 3 + } + }, + "required": [ + "owner", + "repository", + "sha" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "The specific commit in GitHub repository", + "instillUIOrder": 0, + "properties": { + "$ref": "#/$defs/commit/properties" + }, + "required": [], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_LIST_ISSUES": { + "description": "Get the list of all issues in a repository,This can be a pull request or a general issue, and you can tell by the `is-pull-request` field.", + "instillShortDescription": "Get the list of all issues in a repository", + "input": { + "description": "Please input the repository name and owner", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "state": { + "default": "open", + "title": "State", + "description": "State of the issues, can be one of: open, closed, all. Default is open", + "enum": [ + "open", + "closed", + "all" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 10, + "type": "string" + }, + "sort": { + "default": "created", + "title": "Sort", + "description": "Sort the issues by created, updated, popularity, or long-running. Default is created", + "enum": [ + "created", + "updated", + "popularity", + "long-running" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 11, + "type": "string" + }, + "direction": { + "default": "desc", + "title": "Direction", + "description": "Direction of the sort, can be one of: asc, desc. Default is desc", + "enum": [ + "asc", + "desc" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 12, + "type": "string" + }, + "since": { + "default": "2021-01-01T00:00:00Z", + "title": "Since", + "description": "Only issues updated at or after this time are returned. Default is 2021-01-01T00:00:00Z", + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 13, + "type": "string" + }, + "no-pull-request": { + "title": "No Pull Request", + "description": "Whether to `not` include pull requests in the response. Since issue and pr use the same indexing system in GitHub, the API returns all relevant objects (issues and pr). Default is false", + "instillFormat": "boolean", + "instillAcceptFormats": [ + "boolean" + ], + "instillUpstreamTypes": [ + "value" + ], + "instillUIOrder": 14, + "type": "boolean" + } + }, + "required": [ + "owner", + "repository" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "All issues in GitHub repository", + "instillUIOrder": 0, + "properties": { + "issues": { + "description": "An array of issues", + "instillUIOrder": 1, + "title": "Issues", + "instillFormat": "array:object", + "type": "array", + "items": { + "$ref": "#/$defs/issue", + "required": [], + "description": "An issue in GitHub" + } + } + }, + "required": [ + "issues" + ], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_GET_ISSUE": { + "description": "Get an issue. This can be a pull request or a general issue, and you can tell by the `is-pull-request` field.", + "instillShortDescription": "Get an issue.", + "input": { + "description": "Please input the repository name and owner", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository", + "issue-number" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "issue-number": { + "default": 0, + "title": "Issue Number", + "description": "Number of the issue", + "instillFormat": "integer", + "instillAcceptFormats": [ + "integer" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 4, + "type": "integer" + } + }, + "required": [ + "owner", + "repository", + "issue-number" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "The specific issue in GitHub repository", + "instillUIOrder": 0, + "properties": { + "$ref": "#/$defs/issue/properties" + }, + "required": [], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_CREATE_ISSUE": { + "description": "Create an issue", + "input": { + "description": "Please input the repository name and owner", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository", + "title", + "body" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "title": { + "$ref": "#/$defs/issue/properties/title", + "instillUIOrder": 3 + }, + "body": { + "$ref": "#/$defs/issue/properties/body", + "instillUIOrder": 4 + } + }, + "required": [ + "owner", + "repository", + "title", + "body" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "The created issue", + "instillUIOrder": 0, + "properties": { + "$ref": "#/$defs/issue/properties" + }, + "required": [], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + }, + "TASK_CREATE_WEBHOOK": { + "description": "Create a webhook for a repository", + "input": { + "description": "Please input the repository name and owner", + "instillUIOrder": 0, + "instillEditOnNodeFields": [ + "owner", + "repository", + "hook-url", + "events" + ], + "properties": { + "$ref": "#/$defs/repository-info", + "hook-url": { + "title": "Webhook URL", + "description": "URL to send the payload to", + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 3, + "type": "string" + }, + "events": { + "title": "Events", + "description": "Events to trigger the webhook. Please see https://docs.github.com/en/webhooks/webhook-events-and-payloads for more information", + "instillFormat": "array:string", + "instillAcceptFormats": [ + "array" + ], + "instillUpstreamTypes": [ + "reference" + ], + "instillUIOrder": 4, + "type": "array", + "items": { + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "type": "string" + } + }, + "active": { + "title": "Active", + "default": false, + "description": "Whether the webhook is active. Default is false", + "instillFormat": "boolean", + "instillAcceptFormats": [ + "boolean" + ], + "instillUpstreamTypes": [ + "value" + ], + "instillUIOrder": 5, + "type": "boolean" + }, + "content-type": { + "default": "json", + "title": "Content Type", + "description": "Content type of the webhook, can be one of: json, form. Default is json", + "enum": [ + "json", + "form" + ], + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "instillUIOrder": 6, + "type": "string" + }, + "hook-secret": { + "title": "Hook Secret", + "description": "If provided, the secret will be used as the key to generate the HMAC hex digest value for delivery signature headers. (see https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers)", + "instillSecret": true, + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "reference" + ], + "instillUIOrder": 7, + "type": "string" + } + }, + "required": [ + "owner", + "repository", + "hook-url", + "events" + ], + "title": "Input", + "instillFormat": "object", + "type": "object" + }, + "output": { + "description": "The created webhook", + "instillUIOrder": 0, + "properties": { + "id": { + "description": "ID of the webhook", + "instillUIOrder": 1, + "title": "Webhook ID", + "instillFormat": "integer", + "instillAcceptFormats": [ + "integer" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "type": "integer" + }, + "url": { + "description": "URL of the webhook", + "instillUIOrder": 2, + "title": "Webhook URL", + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "type": "string" + }, + "ping-url": { + "description": "URL to ping the webhook", + "instillUIOrder": 3, + "title": "Ping URL", + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "type": "string" + }, + "test-url": { + "description": "URL to test the webhook", + "instillUIOrder": 4, + "title": "Test URL", + "instillFormat": "string", + "instillAcceptFormats": [ + "string" + ], + "instillUpstreamTypes": [ + "value", + "reference" + ], + "type": "string" + }, + "config": { + "description": "Configuration of the webhook", + "instillUIOrder": 6, + "title": "Config", + "instillFormat": "object", + "type": "object", + "properties": { + "url": { + "description": "URL of the webhook", + "instillUIOrder": 1, + "title": "Webhook URL", + "instillFormat": "string", + "type": "string" + }, + "content-type": { + "description": "Content type of the webhook", + "instillUIOrder": 2, + "title": "Content Type", + "instillFormat": "string", + "type": "string" + }, + "insecure-ssl": { + "description": "Whether the webhook is insecure", + "instillUIOrder": 3, + "title": "Insecure SSL", + "instillFormat": "string", + "type": "string" + } + }, + "required": [] + } + }, + "required": [], + "title": "Output", + "instillFormat": "object", + "type": "object" + } + } +} diff --git a/application/github/v0/issue_service_test.go b/application/github/v0/issue_service_test.go new file mode 100644 index 00000000..f9998b60 --- /dev/null +++ b/application/github/v0/issue_service_test.go @@ -0,0 +1,112 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v62/github" +) + +type MockIssuesService struct{} + +func (m *MockIssuesService) ListByRepo(ctx context.Context, owner, repo string, opt *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 201: + return []*github.Issue{}, nil, nil + } + + resp := &github.Response{} + issues := []*github.Issue{} + issues = append(issues, &github.Issue{ + ID: github.Int64(1), + Number: github.Int(1), + Title: github.String("This is a fake Issue"), + Body: github.String("Issue Body"), + State: github.String("open"), + Assignee: &github.User{ + Name: github.String("assignee"), + }, + Assignees: []*github.User{ + { + Name: github.String("assignee1"), + }, + { + Name: github.String("assignee2"), + }, + }, + Labels: []*github.Label{ + { + Name: github.String("label1"), + }, + { + Name: github.String("label2"), + }, + }, + PullRequestLinks: nil, + }) + return issues, resp, nil +} +func (m *MockIssuesService) Get(ctx context.Context, owner, repo string, number int) (*github.Issue, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 201: + return &github.Issue{}, nil, nil + } + resp := &github.Response{} + issue := &github.Issue{ + ID: github.Int64(1), + Number: github.Int(1), + Title: github.String("This is a fake Issue"), + Body: github.String("Issue Body"), + State: github.String("open"), + Assignee: &github.User{ + Name: github.String("assignee"), + }, + Assignees: []*github.User{ + { + Name: github.String("assignee1"), + }, + { + Name: github.String("assignee2"), + }, + }, + Labels: []*github.Label{ + { + Name: github.String("label1"), + }, + { + Name: github.String("label2"), + }, + }, + PullRequestLinks: nil, + } + return issue, resp, nil +} +func (m *MockIssuesService) Create(ctx context.Context, owner, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 201: + return &github.Issue{}, nil, nil + } + resp := &github.Response{} + + newIssue := &github.Issue{ + ID: github.Int64(1), + Number: github.Int(1), + Title: issue.Title, + Body: issue.Body, + State: github.String("open"), + PullRequestLinks: nil, + } + return newIssue, resp, nil +} diff --git a/application/github/v0/issues.go b/application/github/v0/issues.go new file mode 100644 index 00000000..b308f222 --- /dev/null +++ b/application/github/v0/issues.go @@ -0,0 +1,209 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v62/github" + "github.com/instill-ai/component/base" + "google.golang.org/protobuf/types/known/structpb" +) + +type IssuesService interface { + ListByRepo(context.Context, string, string, *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) + Get(context.Context, string, string, int) (*github.Issue, *github.Response, error) + Create(context.Context, string, string, *github.IssueRequest) (*github.Issue, *github.Response, error) + // Edit(context.Context, string, string, int, *github.IssueRequest) (*github.Issue, *github.Response, error) +} + +type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + Assignee string `json:"assignee"` + Assignees []string `json:"assignees"` + Labels []string `json:"labels"` + IsPullRequest bool `json:"is-pull-request"` +} + +func (githubClient *Client) extractIssue(originalIssue *github.Issue) Issue { + return Issue{ + Number: originalIssue.GetNumber(), + Title: originalIssue.GetTitle(), + State: originalIssue.GetState(), + Body: originalIssue.GetBody(), + Assignee: originalIssue.GetAssignee().GetName(), + Assignees: extractAssignees(originalIssue.Assignees), + Labels: extractLabels(originalIssue.Labels), + IsPullRequest: originalIssue.IsPullRequest(), + } +} + +func extractAssignees(assignees []*github.User) []string { + assigneeList := make([]string, len(assignees)) + for idx, assignee := range assignees { + assigneeList[idx] = assignee.GetName() + } + return assigneeList +} + +func extractLabels(labels []*github.Label) []string { + labelList := make([]string, len(labels)) + for idx, label := range labels { + labelList[idx] = label.GetName() + } + return labelList +} + +func (githubClient *Client) getIssue(ctx context.Context, owner, repository string, issueNumber int) (*github.Issue, error) { + issue, _, err := githubClient.Issues.Get(ctx, owner, repository, issueNumber) + if err != nil { + return nil, err + } + return issue, nil +} +func filterOutPullRequests(issues []Issue) []Issue { + filteredIssues := make([]Issue, 0) + for _, issue := range issues { + if !issue.IsPullRequest { + filteredIssues = append(filteredIssues, issue) + } + } + return filteredIssues +} + +type ListIssuesInput struct { + RepoInfo + State string `json:"state"` + Sort string `json:"sort"` + Direction string `json:"direction"` + Since string `json:"since"` + NoPullRequest bool `json:"no-pull-request"` +} + +type ListIssuesResp struct { + Issues []Issue `json:"issues"` +} + +func (githubClient *Client) listIssuesTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + var inputStruct ListIssuesInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + // from format like `2006-01-02T15:04:05Z07:00` to time.Time + sinceTime, err := parseTime(inputStruct.Since) + if err != nil { + return nil, err + } + opts := &github.IssueListByRepoOptions{ + State: inputStruct.State, + Sort: inputStruct.Sort, + Direction: inputStruct.Direction, + Since: *sinceTime, + } + if opts.Mentioned == "none" { + opts.Mentioned = "" + } + + issues, _, err := githubClient.Issues.ListByRepo(ctx, owner, repository, opts) + if err != nil { + return nil, err + } + + issueList := make([]Issue, len(issues)) + for idx, issue := range issues { + issueList[idx] = githubClient.extractIssue(issue) + } + + // filter out pull requests if no-pull-request is true + if inputStruct.NoPullRequest { + issueList = filterOutPullRequests(issueList) + } + var resp ListIssuesResp + resp.Issues = issueList + out, err := base.ConvertToStructpb(resp) + if err != nil { + return nil, err + } + return out, nil +} + +type GetIssueInput struct { + RepoInfo + IssueNumber int `json:"issue-number"` +} + +type GetIssueResp struct { + Issue +} + +func (githubClient *Client) getIssueTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + var inputStruct GetIssueInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + + issueNumber := inputStruct.IssueNumber + issue, err := githubClient.getIssue(ctx, owner, repository, issueNumber) + if err != nil { + return nil, err + } + + issueResp := githubClient.extractIssue(issue) + var resp GetIssueResp + resp.Issue = issueResp + out, err := base.ConvertToStructpb(resp) + if err != nil { + return nil, err + } + return out, nil +} + +type CreateIssueInput struct { + RepoInfo + Title string `json:"title"` + Body string `json:"body"` +} + +type CreateIssueResp struct { + Issue +} + +func (githubClient *Client) createIssueTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + var inputStruct CreateIssueInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + issueRequest := &github.IssueRequest{ + Title: &inputStruct.Title, + Body: &inputStruct.Body, + } + issue, _, err := githubClient.Issues.Create(ctx, owner, repository, issueRequest) + if err != nil { + return nil, err + } + + issueResp := githubClient.extractIssue(issue) + var resp CreateIssueResp + resp.Issue = issueResp + out, err := base.ConvertToStructpb(resp) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/application/github/v0/main.go b/application/github/v0/main.go new file mode 100644 index 00000000..ab8aebfd --- /dev/null +++ b/application/github/v0/main.go @@ -0,0 +1,172 @@ +//go:generate compogen readme ./config ./README.mdx +package github + +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 ( + taskListPRs = "TASK_LIST_PULL_REQUESTS" + taskGetPR = "TASK_GET_PULL_REQUEST" + taskGetCommit = "TASK_GET_COMMIT" + taskGetReviewComments = "TASK_LIST_REVIEW_COMMENTS" + taskCreateReviewComment = "TASK_CREATE_REVIEW_COMMENT" + taskListIssues = "TASK_LIST_ISSUES" + taskGetIssue = "TASK_GET_ISSUE" + taskCreateIssue = "TASK_CREATE_ISSUE" + taskCreateWebhook = "TASK_CREATE_WEBHOOK" +) + +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() + githubClient := newClient(ctx, setup) + e := &execution{ + ComponentExecution: base.ComponentExecution{Component: c, SystemVariables: sysVars, Setup: setup, Task: task}, + client: githubClient, + } + switch task { + case taskListPRs: + e.execute = e.client.listPullRequestsTask + case taskGetPR: + e.execute = e.client.getPullRequestTask + case taskGetReviewComments: + e.execute = e.client.listReviewCommentsTask + case taskCreateReviewComment: + e.execute = e.client.createReviewCommentTask + case taskGetCommit: + e.execute = e.client.getCommitTask + case taskListIssues: + e.execute = e.client.listIssuesTask + case taskGetIssue: + e.execute = e.client.getIssueTask + case taskCreateIssue: + e.execute = e.client.createIssueTask + case taskCreateWebhook: + e.execute = e.client.createWebhookTask + 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) fillInDefaultValues(input *structpb.Struct) (*structpb.Struct, error) { + task := e.Task + 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{}) + for key, value := range inputMap { + valueMap, ok := value.(map[string]interface{}) + if !ok { + continue + } + if _, ok := valueMap["default"]; !ok { + 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), + }, + } + } + } + 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/github/v0/pull_request.go b/application/github/v0/pull_request.go new file mode 100644 index 00000000..bd01f36f --- /dev/null +++ b/application/github/v0/pull_request.go @@ -0,0 +1,182 @@ +package github + +import ( + "context" + + "fmt" + + "github.com/google/go-github/v62/github" + "github.com/instill-ai/component/base" + "github.com/instill-ai/x/errmsg" + "google.golang.org/protobuf/types/known/structpb" +) + +type PullRequestService interface { + List(context.Context, string, string, *github.PullRequestListOptions) ([]*github.PullRequest, *github.Response, error) + Get(context.Context, string, string, int) (*github.PullRequest, *github.Response, error) + ListComments(context.Context, string, string, int, *github.PullRequestListCommentsOptions) ([]*github.PullRequestComment, *github.Response, error) + CreateComment(context.Context, string, string, int, *github.PullRequestComment) (*github.PullRequestComment, *github.Response, error) + ListCommits(context.Context, string, string, int, *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) +} + +type PullRequest struct { + ID int64 `json:"id"` + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` + Body string `json:"body"` + DiffURL string `json:"diff-url,omitempty"` + CommitsURL string `json:"commits-url,omitempty"` + Commits []Commit `json:"commits"` + Head string `json:"head"` + Base string `json:"base"` + CommentsNum int `json:"comments-num"` + CommitsNum int `json:"commits-num"` + ReviewCommentsNum int `json:"review-comments-num"` +} + +func (githubClient *Client) extractPullRequestInformation(ctx context.Context, owner string, repository string, originalPr *github.PullRequest, needCommitDetails bool) (PullRequest, error) { + resp := PullRequest{ + ID: originalPr.GetID(), + Number: originalPr.GetNumber(), + State: originalPr.GetState(), + Title: originalPr.GetTitle(), + Body: originalPr.GetBody(), + DiffURL: originalPr.GetDiffURL(), + Head: originalPr.GetHead().GetSHA(), + Base: originalPr.GetBase().GetSHA(), + CommentsNum: originalPr.GetComments(), + CommitsNum: originalPr.GetCommits(), + ReviewCommentsNum: originalPr.GetReviewComments(), + } + if originalPr.GetCommitsURL() != "" { + commits, _, err := githubClient.PullRequests.ListCommits(ctx, owner, repository, resp.Number, nil) + if err != nil { + return PullRequest{}, err + } + resp.Commits = make([]Commit, len(commits)) + for idx, commit := range commits { + resp.Commits[idx] = githubClient.extractCommitInformation(ctx, owner, repository, commit, needCommitDetails) + } + } + return resp, nil +} + +type ListPullRequestsInput struct { + RepoInfo + State string `json:"state"` + Sort string `json:"sort"` + Direction string `json:"direction"` +} +type ListPullRequestsResp struct { + PullRequests []PullRequest `json:"pull-requests"` +} + +func (githubClient *Client) listPullRequestsTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + + var inputStruct ListPullRequestsInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + + opts := &github.PullRequestListOptions{ + State: inputStruct.State, + Sort: inputStruct.Sort, + Direction: inputStruct.Direction, + } + prs, _, err := githubClient.PullRequests.List(ctx, owner, repository, opts) + if err != nil { + return nil, err + } + PullRequests := make([]PullRequest, len(prs)) + for idx, pr := range prs { + PullRequests[idx], err = githubClient.extractPullRequestInformation(ctx, owner, repository, pr, false) + if err != nil { + return nil, err + } + } + + var prResp ListPullRequestsResp + prResp.PullRequests = PullRequests + out, err := base.ConvertToStructpb(prResp) + if err != nil { + return nil, err + } + return out, nil +} + +type GetPullRequestInput struct { + RepoInfo + PrNumber float64 `json:"pr-number"` +} +type GetPullRequestResp struct { + PullRequest +} + +func (githubClient *Client) getPullRequestTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + + var inputStruct GetPullRequestInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + number := inputStruct.PrNumber + var pullRequest *github.PullRequest + if number > 0 { + pr, _, err := githubClient.PullRequests.Get(ctx, owner, repository, int(number)) + if err != nil { + // err includes the rate limit, 404 not found, etc. + // if the connection is not authorized, it's easy to get rate limit error in large scale usage. + return nil, err + } + pullRequest = pr + } else { + // Get the latest PR + opts := &github.PullRequestListOptions{ + State: "all", + Sort: "created", + Direction: "desc", + } + prs, _, err := githubClient.PullRequests.List(ctx, owner, repository, opts) + if err != nil { + // err includes the rate limit. + // if the connection is not authorized, it's easy to get rate limit error in large scale usage. + return nil, err + } + if len(prs) == 0 { + return nil, errmsg.AddMessage( + fmt.Errorf("no pull request found"), + "No pull request found", + ) + } + pullRequest = prs[0] + // Some fields are not included in the list API, so we need to get the PR again. + pr, _, err := githubClient.PullRequests.Get(ctx, owner, repository, *pullRequest.Number) + if err != nil { + // err includes the rate limit, 404 not found, etc. + // if the connection is not authorized, it's easy to get rate limit error in large scale usage. + return nil, err + } + pullRequest = pr + } + + var prResp GetPullRequestResp + prResp.PullRequest, err = githubClient.extractPullRequestInformation(ctx, owner, repository, pullRequest, true) + if err != nil { + return nil, err + } + out, err := base.ConvertToStructpb(prResp) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/application/github/v0/pull_request_service_test.go b/application/github/v0/pull_request_service_test.go new file mode 100644 index 00000000..236defff --- /dev/null +++ b/application/github/v0/pull_request_service_test.go @@ -0,0 +1,147 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v62/github" +) + +type MockPullRequestService struct { + Client *github.Client +} + +func (m *MockPullRequestService) List(ctx context.Context, owner, repo string, opts *github.PullRequestListOptions) ([]*github.PullRequest, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 201: + return []*github.PullRequest{}, nil, nil + } + + resp := &github.Response{} + prs := []*github.PullRequest{} + diffURL := fmt.Sprintf("%v/%v/%v/pull/%v.diff", fakeHost, owner, repo, 1) + commitsURL := fmt.Sprintf("%v/%v/%v/pull/%v/commits", fakeHost, owner, repo, 1) + prs = append(prs, &github.PullRequest{ + ID: github.Int64(1), + Number: github.Int(1), + Title: github.String("This is a fake PR"), + Body: github.String("PR Body"), + DiffURL: github.String(diffURL), + Head: &github.PullRequestBranch{ + SHA: github.String("headSHA"), + }, + Base: &github.PullRequestBranch{ + SHA: github.String("baseSHA"), + }, + Comments: github.Int(0), + Commits: github.Int(1), + ReviewComments: github.Int(2), + State: github.String("open"), + CommitsURL: github.String(commitsURL), + }) + return prs, resp, nil +} +func (m *MockPullRequestService) Get(ctx context.Context, owner, repo string, number int) (*github.PullRequest, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 201: + return nil, nil, nil + } + resp := &github.Response{} + diffURL := fmt.Sprintf("%v/%v/%v/pull/%v.diff", fakeHost, owner, repo, number) + commitsURL := fmt.Sprintf("%v/%v/%v/pull/%v/commits", fakeHost, owner, repo, number) + prs := &github.PullRequest{ + ID: github.Int64(1), + Number: github.Int(number), + Title: github.String("This is a fake PR"), + Body: github.String("PR Body"), + DiffURL: github.String(diffURL), + Head: &github.PullRequestBranch{ + SHA: github.String("headSHA"), + }, + Base: &github.PullRequestBranch{ + SHA: github.String("baseSHA"), + }, + Comments: github.Int(0), + Commits: github.Int(1), + ReviewComments: github.Int(2), + State: github.String("open"), + CommitsURL: github.String(commitsURL), + } + return prs, resp, nil +} +func (m *MockPullRequestService) ListComments(ctx context.Context, owner, repo string, number int, opts *github.PullRequestListCommentsOptions) ([]*github.PullRequestComment, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 201: + return []*github.PullRequestComment{}, nil, nil + } + resp := &github.Response{} + comments := []*github.PullRequestComment{} + comments = append(comments, &github.PullRequestComment{ + ID: github.Int64(1), + Body: github.String("This is a fake comment"), + }) + return comments, resp, nil +} +func (m *MockPullRequestService) CreateComment(ctx context.Context, owner, repo string, number int, comment *github.PullRequestComment) (*github.PullRequestComment, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 422: + return nil, nil, fmt.Errorf("422 Unprocessable Entity") + case 201: + return nil, nil, nil + } + if comment.StartLine != nil && *comment.Line <= *comment.StartLine { + return nil, nil, fmt.Errorf("422 Unprocessable Entity") + } + + resp := &github.Response{} + comment.ID = github.Int64(1) + return comment, resp, nil +} +func (m *MockPullRequestService) ListCommits(ctx context.Context, owner, repo string, number int, opts *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 201: + return []*github.RepositoryCommit{}, nil, nil + } + resp := &github.Response{} + commits := []*github.RepositoryCommit{} + commits = append(commits, &github.RepositoryCommit{ + SHA: github.String("commitSHA"), + Commit: &github.Commit{ + Message: github.String("This is a fake commit"), + }, + Stats: &github.CommitStats{ + Additions: github.Int(1), + Deletions: github.Int(1), + Total: github.Int(2), + }, + Files: []*github.CommitFile{ + { + Filename: github.String("filename"), + Patch: github.String("patch"), + Additions: github.Int(1), + Deletions: github.Int(1), + Changes: github.Int(2), + }, + }, + }) + return commits, resp, nil + +} diff --git a/application/github/v0/repositories_service_test.go b/application/github/v0/repositories_service_test.go new file mode 100644 index 00000000..0dac5362 --- /dev/null +++ b/application/github/v0/repositories_service_test.go @@ -0,0 +1,105 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v62/github" +) + +type MockRepositoriesService struct{} + +func (m *MockRepositoriesService) GetCommit(ctx context.Context, owner, repo, sha string, opts *github.ListOptions) (*github.RepositoryCommit, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 422: + return nil, nil, fmt.Errorf("422 Unprocessable Entity") + case 201: + return nil, nil, nil + } + + resp := &github.Response{} + commit := &github.RepositoryCommit{ + SHA: github.String("commitSHA"), + Commit: &github.Commit{ + Message: github.String("This is a fake commit"), + }, + Stats: &github.CommitStats{ + Additions: github.Int(1), + Deletions: github.Int(1), + Total: github.Int(2), + }, + Files: []*github.CommitFile{ + { + Filename: github.String("filename"), + Patch: github.String("patch"), + Additions: github.Int(1), + Deletions: github.Int(1), + Changes: github.Int(2), + }, + }, + } + return commit, resp, nil +} +func (m *MockRepositoriesService) ListCommits(ctx context.Context, owner, repo string, opts *github.CommitsListOptions) ([]*github.RepositoryCommit, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 201: + return []*github.RepositoryCommit{}, nil, nil + } + resp := &github.Response{} + commits := []*github.RepositoryCommit{} + commits = append(commits, &github.RepositoryCommit{ + SHA: github.String("commitSHA"), + Commit: &github.Commit{ + Message: github.String("This is a fake commit"), + }, + Stats: &github.CommitStats{ + Additions: github.Int(1), + Deletions: github.Int(1), + Total: github.Int(2), + }, + Files: []*github.CommitFile{ + { + Filename: github.String("filename"), + Patch: github.String("patch"), + Additions: github.Int(1), + Deletions: github.Int(1), + Changes: github.Int(2), + }, + }, + }) + return commits, resp, nil +} + +// CreateHook(context.Context, string, string, *github.Hook) (*github.Hook, *github.Response, error) +func (m *MockRepositoriesService) CreateHook(ctx context.Context, owner, repo string, hook *github.Hook) (*github.Hook, *github.Response, error) { + switch middleWare(owner) { + case 403: + return nil, nil, fmt.Errorf("403 API rate limit exceeded") + case 404: + return nil, nil, fmt.Errorf("404 Not Found") + case 201: + return nil, nil, nil + } + + resp := &github.Response{} + hookResp := &github.Hook{ + ID: github.Int64(1), + Name: github.String("hookName"), + Active: github.Bool(true), + URL: github.String("hook_url"), + PingURL: github.String("ping_url"), + TestURL: github.String("test_url"), + Config: &github.HookConfig{ + URL: github.String("hook_url"), + InsecureSSL: github.String("0"), + ContentType: github.String("json"), + }, + } + return hookResp, resp, nil +} diff --git a/application/github/v0/review_comment.go b/application/github/v0/review_comment.go new file mode 100644 index 00000000..20294703 --- /dev/null +++ b/application/github/v0/review_comment.go @@ -0,0 +1,151 @@ +package github + +import ( + "context" + "strings" + + "github.com/google/go-github/v62/github" + "github.com/instill-ai/component/base" + "github.com/instill-ai/x/errmsg" + "google.golang.org/protobuf/types/known/structpb" +) + +type ReviewComment struct { + github.PullRequestComment +} + +func extractReviewCommentInformation(originalComment *github.PullRequestComment) ReviewComment { + return ReviewComment{ + PullRequestComment: *originalComment, + } +} + +type ListReviewCommentsInput struct { + RepoInfo + PrNumber int `json:"pr-number"` + Sort string `json:"sort"` + Direction string `json:"direction"` + Since string `json:"since"` +} + +type ListReviewCommentsResp struct { + ReviewComments []ReviewComment `json:"comments"` +} + +// ListReviewComments retrieves all review comments for a given pull request. +// Specifying a pull request number of 0 will return all comments on all pull requests for the repository. +// +// * This only works for public repositories. +func (githubClient *Client) listReviewCommentsTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + var inputStruct ListReviewCommentsInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + // from format like `2006-01-02T15:04:05Z07:00` to time.Time + sinceTime, err := parseTime(inputStruct.Since) + if err != nil { + return nil, err + } + opts := &github.PullRequestListCommentsOptions{ + Sort: inputStruct.Sort, + Direction: inputStruct.Direction, + Since: *sinceTime, + } + number := inputStruct.PrNumber + comments, _, err := githubClient.PullRequests.ListComments(ctx, owner, repository, number, opts) + if err != nil { + errMessage := strings.Split(err.Error(), ": ") + if len(errMessage) < 2 { + return nil, err + } + errType := strings.TrimSpace(errMessage[1]) + if strings.Contains(errType, "404 Not Found") { + return nil, errmsg.AddMessage( + err, + "Pull request not found. Ensure the pull request number is correct, the repository is public, and fill in the correct GitHub token.", + ) + } + return nil, err + } + + reviewComments := make([]ReviewComment, len(comments)) + for i, comment := range comments { + reviewComments[i] = extractReviewCommentInformation(comment) + } + var reviewCommentsResp ListReviewCommentsResp + reviewCommentsResp.ReviewComments = reviewComments + out, err := base.ConvertToStructpb(reviewCommentsResp) + if err != nil { + return nil, err + } + return out, nil +} + +type CreateReviewCommentInput struct { + RepoInfo + PrNumber int `json:"pr-number"` + Comment github.PullRequestComment `json:"comment"` +} + +type CreateReviewCommentResp struct { + ReviewComment +} + +// CreateReviewComment creates a review comment for a given pull request. +// +// * This only works for public repositories. +func (githubClient *Client) createReviewCommentTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + var commentInput CreateReviewCommentInput + err := base.ConvertFromStructpb(props, &commentInput) + if err != nil { + return nil, err + } + + owner, repository, err := parseTargetRepo(commentInput) + if err != nil { + return nil, err + } + number := commentInput.PrNumber + commentReqs := &commentInput.Comment + commentReqs.Position = commentReqs.Line // Position is deprecated, use Line instead + + if *commentReqs.Line == *commentReqs.StartLine { + commentReqs.StartLine = nil // If it's a one line comment, don't send start-line + } + comment, _, err := githubClient.PullRequests.CreateComment(ctx, owner, repository, number, commentReqs) + if err != nil { + errMessage := strings.Split(err.Error(), ": ") + if len(errMessage) < 2 { + return nil, err + } + errType := strings.TrimSpace(errMessage[1]) + if strings.Contains(errType, "404 Not Found") { + return nil, errmsg.AddMessage( + err, + "Pull request not found. Ensure the pull request number is correct, the repository is public, and fill in the correct GitHub token.", + ) + } + if strings.Contains(errType, "422 Validation Failed") { + return nil, errmsg.AddMessage( + err, + "Invalid comment. Ensure the comment is not empty and the line numbers and sides are correct.", + ) + } + return nil, err + } + + reviewComment := extractReviewCommentInformation(comment) + var reviewCommentResp CreateReviewCommentResp + reviewCommentResp.ReviewComment = reviewComment + out, err := base.ConvertToStructpb(reviewCommentResp) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/application/github/v0/utils.go b/application/github/v0/utils.go new file mode 100644 index 00000000..e5ed5b34 --- /dev/null +++ b/application/github/v0/utils.go @@ -0,0 +1,35 @@ +package github + +import ( + "fmt" + "time" + + "github.com/instill-ai/x/errmsg" +) + +func middleWare(req string) int { + if req == "rate_limit" { + return 403 + } + if req == "not_found" { + return 404 + } + if req == "unprocessable_entity" { + return 422 + } + if req == "no_pr" { + return 201 + } + return 200 +} + +func parseTime(since string) (*time.Time, error) { + sinceTime, err := time.Parse(time.RFC3339, since) + if err != nil { + return nil, errmsg.AddMessage( + fmt.Errorf("invalid time format"), + fmt.Sprintf("Cannot parse time: \"%s\". Please provide RFC3339 format(like %s)", since, time.RFC3339), + ) + } + return &sinceTime, nil +} diff --git a/application/github/v0/webhook.go b/application/github/v0/webhook.go new file mode 100644 index 00000000..40edb3cb --- /dev/null +++ b/application/github/v0/webhook.go @@ -0,0 +1,98 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v62/github" + "github.com/instill-ai/component/base" + "google.golang.org/protobuf/types/known/structpb" +) + +type CreateWebHookInput struct { + RepoInfo + HookURL string `json:"hook-url"` + HookSecret string `json:"hook-secret"` + Events []string `json:"events"` + Active bool `json:"active"` + ContentType string `json:"content-type"` // including `json`, `form` +} + +type HookConfig struct { + URL string `json:"url"` + InsecureSSL string `json:"insecure-ssl"` + Secret string `json:"secret,omitempty"` + ContentType string `json:"content-type"` +} + +type HookInfo struct { + ID int64 `json:"id"` + URL string `json:"url"` + PingURL string `json:"ping-url"` + TestURL string `json:"test-url"` + Config HookConfig `json:"config"` +} +type CreateWebHookResp struct { + HookInfo +} + +func (githubClient *Client) extractHook(originalHook *github.Hook) HookInfo { + return HookInfo{ + ID: originalHook.GetID(), + URL: originalHook.GetURL(), + PingURL: originalHook.GetPingURL(), + TestURL: originalHook.GetTestURL(), + Config: HookConfig{ + URL: originalHook.GetConfig().GetURL(), + InsecureSSL: originalHook.GetConfig().GetInsecureSSL(), + Secret: originalHook.GetConfig().GetSecret(), + ContentType: originalHook.GetConfig().GetContentType(), + }, + } +} + +func (githubClient *Client) createWebhookTask(ctx context.Context, props *structpb.Struct) (*structpb.Struct, error) { + var inputStruct CreateWebHookInput + err := base.ConvertFromStructpb(props, &inputStruct) + if err != nil { + return nil, err + } + + owner, repository, err := parseTargetRepo(inputStruct) + if err != nil { + return nil, err + } + hookURL := inputStruct.HookURL + hookSecret := inputStruct.HookSecret + originalEvents := inputStruct.Events + active := inputStruct.Active + contentType := inputStruct.ContentType + if contentType != "json" && contentType != "form" { + contentType = "json" + } + + hook := &github.Hook{ + Name: github.String("web"), // only webhooks are supported + Config: &github.HookConfig{ + InsecureSSL: github.String("0"), // SSL verification is required + URL: &hookURL, + Secret: &hookSecret, + ContentType: &contentType, + }, + Events: originalEvents, + Active: &active, + } + + hook, _, err = githubClient.Repositories.CreateHook(ctx, owner, repository, hook) + if err != nil { + return nil, err + } + + var resp CreateWebHookResp + hookInfo := githubClient.extractHook(hook) + resp.HookInfo = hookInfo + out, err := base.ConvertToStructpb(resp) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go.mod b/go.mod index 039a3943..1b845a32 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/gocolly/colly/v2 v2.1.0 github.com/gofrs/uuid v4.4.0+incompatible github.com/gojuno/minimock/v3 v3.3.6 + github.com/google/go-github/v62 v62.0.0 github.com/h2non/filetype v1.1.3 github.com/instill-ai/protogen-go v0.3.3-alpha.0.20240530065422-d384f728a1e2 github.com/instill-ai/x v0.4.0-alpha @@ -38,6 +39,7 @@ require ( github.com/tmc/langchaingo v0.1.10 go.uber.org/zap v1.24.0 golang.org/x/image v0.18.0 + golang.org/x/oauth2 v0.18.0 golang.org/x/text v0.16.0 google.golang.org/api v0.172.0 google.golang.org/grpc v1.64.1 @@ -76,6 +78,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/flatbuffers v23.5.26+incompatible // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect @@ -122,7 +125,6 @@ require ( golang.org/x/crypto v0.24.0 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/net v0.26.0 // indirect - golang.org/x/oauth2 v0.18.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect diff --git a/go.sum b/go.sum index ede34288..ac458f8b 100644 --- a/go.sum +++ b/go.sum @@ -148,12 +148,17 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= diff --git a/store/store.go b/store/store.go index 008e9217..fd5e8d70 100644 --- a/store/store.go +++ b/store/store.go @@ -17,6 +17,7 @@ import ( "github.com/instill-ai/component/ai/openai/v0" "github.com/instill-ai/component/ai/stabilityai/v0" "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/numbers/v0" @@ -82,6 +83,7 @@ func Init( compStore.Import(text.Init(baseComp)) compStore.Import(document.Init(baseComp)) + compStore.Import(github.Init(baseComp)) { // StabilityAI conn := stabilityai.Init(baseComp)