This is an automated email from the ASF dual-hosted git repository.

zky pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 16f97a1a7 feat(github): adds authentication via github app (#5077)
16f97a1a7 is described below

commit 16f97a1a7605d5ce7cf391bbec1270eec7c77b6e
Author: Marek Magdziak <[email protected]>
AuthorDate: Thu May 25 10:47:09 2023 +0200

    feat(github): adds authentication via github app (#5077)
    
    The change adds new authentication option in addition to PAT. The main 
motivation is that Github Apps have higher rate limits compared to PATs.
    
    Closes issue 4801
    
    Co-authored-by: Marek Magdziak <[email protected]>
    Co-authored-by: Louis.z <[email protected]>
---
 backend/plugins/github/api/blueprint_v200.go       |   8 +
 backend/plugins/github/api/connection.go           | 166 +++++++++++-----
 backend/plugins/github/models/connection.go        | 113 ++++++++++-
 .../migrationscripts/20230428_add_multi_auth.go    |  63 ++++++
 .../github/models/migrationscripts/register.go     |   1 +
 config-ui/src/plugins/register/github/api.ts       |   8 +
 .../miller-columns/use-miller-columns.ts           | 121 ++++++++----
 config-ui/src/plugins/register/github/config.tsx   |  40 +++-
 .../github/connection-fields/authentication.tsx}   |  51 ++---
 .../github/connection-fields/githubapp.tsx         | 217 +++++++++++++++++++++
 .../register/github/connection-fields/index.ts     |   2 +
 .../register/github/connection-fields/styled.ts    |   2 +-
 .../register/github/connection-fields/token.tsx    |   9 +-
 config-ui/src/store/connections/api.ts             |   2 +
 config-ui/src/store/connections/context.tsx        |   8 +
 config-ui/src/store/connections/types.ts           |   2 +
 16 files changed, 676 insertions(+), 137 deletions(-)

diff --git a/backend/plugins/github/api/blueprint_v200.go 
b/backend/plugins/github/api/blueprint_v200.go
index cd8def7c8..fac202d45 100644
--- a/backend/plugins/github/api/blueprint_v200.go
+++ b/backend/plugins/github/api/blueprint_v200.go
@@ -18,6 +18,7 @@ limitations under the License.
 package api
 
 import (
+       "context"
        "fmt"
        "net/url"
        "strings"
@@ -47,6 +48,13 @@ func MakeDataSourcePipelinePlanV200(subtaskMetas 
[]plugin.SubTaskMeta, connectio
                return nil, nil, err
        }
 
+       // needed for the connection to populate its access tokens
+       // if AppKey authentication method is selected
+       _, err = helper.NewApiClientFromConnection(context.TODO(), basicRes, 
connection)
+       if err != nil {
+               return nil, nil, err
+       }
+
        plan := make(plugin.PipelinePlan, len(bpScopes))
        plan, err = makeDataSourcePipelinePlanV200(subtaskMetas, plan, 
bpScopes, connection, syncPolicy)
        if err != nil {
diff --git a/backend/plugins/github/api/connection.go 
b/backend/plugins/github/api/connection.go
index 76e612701..74b28a7bd 100644
--- a/backend/plugins/github/api/connection.go
+++ b/backend/plugins/github/api/connection.go
@@ -28,6 +28,7 @@ import (
        "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
        "github.com/apache/incubator-devlake/plugins/github/models"
        "github.com/apache/incubator-devlake/server/api/shared"
+       "github.com/mitchellh/mapstructure"
 )
 
 var publicPermissions = []string{"repo:status", "repo_deployment", 
"read:user", "read:org"}
@@ -53,8 +54,9 @@ func findMissingPerms(userPerms map[string]bool, 
requiredPerms []string) []strin
 
 type GithubTestConnResponse struct {
        shared.ApiBody
-       Login   string `json:"login"`
-       Warning bool   `json:"warning"`
+       Login         string                         `json:"login"`
+       Warning       bool                           `json:"warning"`
+       Installations []models.GithubAppInstallation `json:"installations"`
 }
 
 // @Summary test github connection
@@ -68,72 +70,130 @@ type GithubTestConnResponse struct {
 func TestConnection(input *plugin.ApiResourceInput) 
(*plugin.ApiResourceOutput, errors.Error) {
        // process input
        var conn models.GithubConn
-       err := api.Decode(input.Body, &conn, vld)
-       if err != nil {
-               return nil, err
+       e := mapstructure.Decode(input.Body, &conn)
+       if e != nil {
+               return nil, errors.Convert(e)
+       }
+       e = vld.StructExcept(conn, "GithubAppKey", "GithubAccessToken")
+       if e != nil {
+               return nil, errors.Convert(e)
        }
 
        apiClient, err := api.NewApiClientFromConnection(context.TODO(), 
basicRes, &conn)
        if err != nil {
                return nil, err
        }
-       res, err := apiClient.Get("user", nil, nil)
-       if err != nil {
-               return nil, errors.BadInput.Wrap(err, "verify token failed")
-       }
 
-       if res.StatusCode == http.StatusUnauthorized {
-               return nil, 
errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error when 
testing connection")
-       }
+       githubApiResponse := &GithubTestConnResponse{}
 
-       if res.StatusCode != http.StatusOK {
-               return nil, errors.HttpStatus(res.StatusCode).New("unexpected 
status code while testing connection")
-       }
+       if conn.AuthMethod == "AppKey" {
+               jwt, err := conn.GithubAppKey.CreateJwt()
+               if err != nil {
+                       return nil, err
+               }
 
-       githubUserOfToken := &models.GithubUserOfToken{}
-       err = api.UnmarshalResponse(res, githubUserOfToken)
-       if err != nil {
-               return nil, errors.BadInput.Wrap(err, "verify token failed")
-       } else if githubUserOfToken.Login == "" {
-               return nil, errors.BadInput.Wrap(err, "invalid token")
-       }
+               res, err := apiClient.Get("app", nil, http.Header{
+                       "Authorization": []string{fmt.Sprintf("Bearer %s", 
jwt)},
+               })
+
+               if err != nil {
+                       return nil, errors.BadInput.Wrap(err, "verify token 
failed")
+               }
+               if res.StatusCode != http.StatusOK {
+                       return nil, 
errors.HttpStatus(res.StatusCode).New("unexpected status code while testing 
connection")
+               }
 
-       success := false
-       warning := false
-       messages := []string{}
-       // for github classic token, check permission
-       if strings.HasPrefix(conn.Token, "ghp_") {
-               scopes := res.Header.Get("X-OAuth-Scopes")
-               // convert "X-OAuth-Scopes" header to user permissions map
-               userPerms := map[string]bool{}
-               for _, userPerm := range strings.Split(scopes, ", ") {
-                       userPerms[userPerm] = true
+               githubApp := &models.GithubApp{}
+               err = api.UnmarshalResponse(res, githubApp)
+               if err != nil {
+                       return nil, errors.BadInput.Wrap(err, "verify token 
failed")
+               } else if githubApp.Slug == "" {
+                       return nil, errors.BadInput.Wrap(err, "invalid token")
                }
-               // check public repo permission
-               missingPubPerms := findMissingPerms(userPerms, 
publicPermissions)
-               success = len(missingPubPerms) == 0
-               if !success {
-                       messages = append(messages, fmt.Sprintf(
-                               "%s is/are required to collect data from Public 
Repos",
-                               strings.Join(missingPubPerms, ", "),
-                       ))
+
+               res, err = apiClient.Get("app/installations", nil, http.Header{
+                       "Authorization": []string{fmt.Sprintf("Bearer %s", 
jwt)},
+               })
+
+               if err != nil {
+                       return nil, errors.BadInput.Wrap(err, "verify token 
failed")
                }
-               // check private repo permission
-               missingPriPerms := findMissingPerms(userPerms, 
privatePermissions)
-               warning = len(missingPriPerms) > 0
-               if warning {
-                       messages = append(messages, fmt.Sprintf(
-                               "%s is/are required to collect data from 
Private Repos",
-                               strings.Join(missingPriPerms, ", "),
-                       ))
+               if res.StatusCode != http.StatusOK {
+                       return nil, 
errors.HttpStatus(res.StatusCode).New("unexpected status code while testing 
connection")
                }
+
+               githubAppInstallations := &[]models.GithubAppInstallation{}
+               err = api.UnmarshalResponse(res, githubAppInstallations)
+               if err != nil {
+                       return nil, errors.BadInput.Wrap(err, "verify token 
failed")
+               }
+
+               githubApiResponse.Success = true
+               githubApiResponse.Message = "success"
+               githubApiResponse.Login = githubApp.Slug
+               githubApiResponse.Installations = *githubAppInstallations
+
+       } else if conn.AuthMethod == "AccessToken" {
+               res, err := apiClient.Get("user", nil, nil)
+               if err != nil {
+                       return nil, errors.BadInput.Wrap(err, "verify token 
failed")
+               }
+
+               if res.StatusCode == http.StatusUnauthorized {
+                       return nil, 
errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error when 
testing connection")
+               }
+
+               if res.StatusCode != http.StatusOK {
+                       return nil, 
errors.HttpStatus(res.StatusCode).New("unexpected status code while testing 
connection")
+               }
+
+               githubUserOfToken := &models.GithubUserOfToken{}
+               err = api.UnmarshalResponse(res, githubUserOfToken)
+               if err != nil {
+                       return nil, errors.BadInput.Wrap(err, "verify token 
failed")
+               } else if githubUserOfToken.Login == "" {
+                       return nil, errors.BadInput.Wrap(err, "invalid token")
+               }
+
+               success := false
+               warning := false
+               messages := []string{}
+               // for github classic token, check permission
+               if strings.HasPrefix(conn.Token, "ghp_") {
+                       scopes := res.Header.Get("X-OAuth-Scopes")
+                       // convert "X-OAuth-Scopes" header to user permissions 
map
+                       userPerms := map[string]bool{}
+                       for _, userPerm := range strings.Split(scopes, ", ") {
+                               userPerms[userPerm] = true
+                       }
+                       // check public repo permission
+                       missingPubPerms := findMissingPerms(userPerms, 
publicPermissions)
+                       success = len(missingPubPerms) == 0
+                       if !success {
+                               messages = append(messages, fmt.Sprintf(
+                                       "%s is/are required to collect data 
from Public Repos",
+                                       strings.Join(missingPubPerms, ", "),
+                               ))
+                       }
+                       // check private repo permission
+                       missingPriPerms := findMissingPerms(userPerms, 
privatePermissions)
+                       warning = len(missingPriPerms) > 0
+                       if warning {
+                               messages = append(messages, fmt.Sprintf(
+                                       "%s is/are required to collect data 
from Private Repos",
+                                       strings.Join(missingPriPerms, ", "),
+                               ))
+                       }
+               }
+
+               githubApiResponse.Success = success
+               githubApiResponse.Warning = warning
+               githubApiResponse.Message = strings.Join(messages, ";\n")
+               githubApiResponse.Login = githubUserOfToken.Login
+       } else {
+               return nil, errors.BadInput.New("invalid authentication method")
        }
 
-       githubApiResponse := &GithubTestConnResponse{}
-       githubApiResponse.Success = success
-       githubApiResponse.Warning = warning
-       githubApiResponse.Message = strings.Join(messages, ";\n")
-       githubApiResponse.Login = githubUserOfToken.Login
        return &plugin.ApiResourceOutput{Body: githubApiResponse, Status: 
http.StatusOK}, nil
 }
 
diff --git a/backend/plugins/github/models/connection.go 
b/backend/plugins/github/models/connection.go
index d634c4889..b321ff86b 100644
--- a/backend/plugins/github/models/connection.go
+++ b/backend/plugins/github/models/connection.go
@@ -18,13 +18,17 @@ limitations under the License.
 package models
 
 import (
+       "encoding/json"
        "fmt"
+       "io"
        "net/http"
        "strings"
+       "time"
 
        "github.com/apache/incubator-devlake/core/errors"
        helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
        
"github.com/apache/incubator-devlake/helpers/pluginhelper/api/apihelperabstract"
+       "github.com/golang-jwt/jwt/v5"
 )
 
 // GithubAccessToken supports fetching data with multiple tokens
@@ -34,28 +38,51 @@ type GithubAccessToken struct {
        tokenIndex         int      `gorm:"-" json:"-" mapstructure:"-"`
 }
 
+type GithubAppKey struct {
+       helper.AppKey  `mapstructure:",squash"`
+       InstallationID int `mapstructure:"installationId" validate:"required" 
json:"installationId"`
+}
+
 // GithubConn holds the essential information to connect to the Github API
 type GithubConn struct {
        helper.RestConnection `mapstructure:",squash"`
+       helper.MultiAuth      `mapstructure:",squash"`
        GithubAccessToken     `mapstructure:",squash"`
+       GithubAppKey          `mapstructure:",squash"`
 }
 
 // PrepareApiClient splits Token to tokens for SetupAuthentication to utilize
 func (conn *GithubConn) PrepareApiClient(apiClient 
apihelperabstract.ApiClientAbstract) errors.Error {
-       conn.tokens = strings.Split(conn.Token, ",")
+
+       if conn.AuthMethod == "AccessToken" {
+               conn.tokens = strings.Split(conn.Token, ",")
+       }
+
+       if conn.AuthMethod == "AppKey" && conn.InstallationID != 0 {
+               token, err := conn.getInstallationAccessToken(apiClient)
+               if err != nil {
+                       return err
+               }
+
+               conn.Token = token.Token
+               conn.tokens = []string{token.Token}
+       }
+
        return nil
 }
 
 // SetupAuthentication sets up the HTTP Request Authentication
-func (gat *GithubAccessToken) SetupAuthentication(req *http.Request) 
errors.Error {
+func (conn *GithubConn) SetupAuthentication(req *http.Request) errors.Error {
        // Rotates token on each request.
-       req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", 
gat.tokens[gat.tokenIndex]))
-       // Set next token index
-       gat.tokenIndex = (gat.tokenIndex + 1) % len(gat.tokens)
+       if len(conn.tokens) > 0 {
+               req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", 
conn.tokens[conn.tokenIndex]))
+               // Set next token index
+               conn.tokenIndex = (conn.tokenIndex + 1) % len(conn.tokens)
+       }
+
        return nil
 }
 
-// GetTokensCount returns total number of tokens
 func (gat *GithubAccessToken) GetTokensCount() int {
        return len(gat.tokens)
 }
@@ -75,3 +102,77 @@ func (GithubConnection) TableName() string {
 type GithubUserOfToken struct {
        Login string `json:"login"`
 }
+
+type InstallationToken struct {
+       Token string `json:"token"`
+}
+
+type GithubApp struct {
+       ID   int32  `json:"id"`
+       Slug string `json:"slug"`
+}
+
+type GithubAppInstallation struct {
+       Id      int `json:"id"`
+       Account struct {
+               Login string `json:"login"`
+       } `json:"account"`
+}
+
+type GithubAppInstallationWithToken struct {
+       GithubAppInstallation
+       Token string
+}
+
+func (gak *GithubAppKey) CreateJwt() (string, errors.Error) {
+       token := jwt.New(jwt.SigningMethodRS256)
+       t := time.Now().Unix()
+
+       token.Claims = jwt.MapClaims{
+               "iat": t,
+               "exp": t + (10 * 60),
+               "iss": gak.AppId,
+       }
+
+       privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(gak.SecretKey))
+       if err != nil {
+               return "", errors.BadInput.Wrap(err, "invalid private key")
+       }
+
+       tokenString, err := token.SignedString(privateKey)
+       if err != nil {
+               return "", errors.BadInput.Wrap(err, "invalid private key")
+       }
+
+       return tokenString, nil
+}
+
+func (gak *GithubAppKey) getInstallationAccessToken(
+       apiClient apihelperabstract.ApiClientAbstract,
+) (*InstallationToken, errors.Error) {
+
+       jwt, err := gak.CreateJwt()
+       if err != nil {
+               return nil, err
+       }
+
+       resp, err := 
apiClient.Post(fmt.Sprintf("/app/installations/%d/access_tokens", 
gak.InstallationID), nil, nil, http.Header{
+               "Authorization": []string{fmt.Sprintf("Bearer %s", jwt)},
+       })
+       if err != nil {
+               return nil, err
+       }
+
+       body, err := errors.Convert01(io.ReadAll(resp.Body))
+       if err != nil {
+               return nil, err
+       }
+
+       var installationToken InstallationToken
+       err = errors.Convert(json.Unmarshal(body, &installationToken))
+       if err != nil {
+               return nil, err
+       }
+
+       return &installationToken, nil
+}
diff --git 
a/backend/plugins/github/models/migrationscripts/20230428_add_multi_auth.go 
b/backend/plugins/github/models/migrationscripts/20230428_add_multi_auth.go
new file mode 100644
index 000000000..ca0b6c912
--- /dev/null
+++ b/backend/plugins/github/models/migrationscripts/20230428_add_multi_auth.go
@@ -0,0 +1,63 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/errors"
+)
+
+type githubMultiAuth20230428 struct {
+       AppId          string
+       SecretKey      string
+       AuthMethod     string
+       InstallationId int
+}
+
+func (githubMultiAuth20230428) TableName() string {
+       return "_tool_github_connections"
+}
+
+type addGithubMultiAuth struct{}
+
+func (*addGithubMultiAuth) Up(res context.BasicRes) errors.Error {
+       db := res.GetDal()
+       err := db.AutoMigrate(&githubMultiAuth20230428{})
+       if err != nil {
+               return err
+       }
+       err = db.UpdateColumn(
+               &GithubConnection20221111{},
+               `auth_method`,
+               "AccessToken",
+               dal.Where(`token IS NOT NULL`),
+       )
+       if err != nil {
+               return err
+       }
+       return err
+}
+
+func (*addGithubMultiAuth) Version() uint64 {
+       return 20230428000010
+}
+
+func (*addGithubMultiAuth) Name() string {
+       return "UpdateSchemas for addGithubMultiAuth"
+}
diff --git a/backend/plugins/github/models/migrationscripts/register.go 
b/backend/plugins/github/models/migrationscripts/register.go
index 5139a53f8..f11587f34 100644
--- a/backend/plugins/github/models/migrationscripts/register.go
+++ b/backend/plugins/github/models/migrationscripts/register.go
@@ -38,5 +38,6 @@ func All() []plugin.MigrationScript {
                new(addEnvToRunAndJob),
                new(addGithubCommitAuthorInfo),
                new(fixRunNameToText),
+               new(addGithubMultiAuth),
        }
 }
diff --git a/config-ui/src/plugins/register/github/api.ts 
b/config-ui/src/plugins/register/github/api.ts
index b0edde388..e202ffee7 100644
--- a/config-ui/src/plugins/register/github/api.ts
+++ b/config-ui/src/plugins/register/github/api.ts
@@ -25,6 +25,14 @@ type PaginationParams = {
 
 export const getUser = (prefix: string) => request(`${prefix}/user`);
 
+export const getInstallationRepos = (prefix: string, params: PaginationParams) 
=>
+  request(`${prefix}/installation/repositories`, {
+    method: 'get',
+    data: {
+      ...params,
+    },
+  });
+
 export const getUserOrgs = (prefix: string, params: PaginationParams) =>
   request(`${prefix}/user/orgs`, {
     method: 'get',
diff --git 
a/config-ui/src/plugins/register/github/components/miller-columns/use-miller-columns.ts
 
b/config-ui/src/plugins/register/github/components/miller-columns/use-miller-columns.ts
index e93098581..04f003218 100644
--- 
a/config-ui/src/plugins/register/github/components/miller-columns/use-miller-columns.ts
+++ 
b/config-ui/src/plugins/register/github/components/miller-columns/use-miller-columns.ts
@@ -23,6 +23,7 @@ import { useProxyPrefix } from '@/hooks';
 
 import type { ScopeItemType } from '../../types';
 import * as API from '../../api';
+import { getConnection } from '@/pages/blueprint/connection-detail/api';
 
 const DEFAULT_PAGE_SIZE = 30;
 
@@ -83,42 +84,75 @@ export const useMillerColumns = ({ connectionId }: 
UseMillerColumnsProps) => {
 
   useEffect(() => {
     (async () => {
-      const user = await API.getUser(prefix);
-      const orgs = await API.getUserOrgs(prefix, {
-        page: 1,
-        per_page: DEFAULT_PAGE_SIZE,
-      });
-
-      const loaded = !orgs.length || orgs.length < DEFAULT_PAGE_SIZE;
-
-      setUser(user);
-      setLoaded(loaded, 'root', 2);
-      setItems([
-        {
-          parentId: null,
-          id: user.login,
-          title: user.login,
-          type: 'org',
-        },
-        ...formatOrgs(orgs),
-      ]);
+      const connection = await getConnection('github', connectionId);
+
+      if (connection.authMethod === 'AppKey') {
+        const appInstallationRepos = await API.getInstallationRepos(prefix, {
+          page: 1,
+          per_page: 1,
+        });
+
+        setUser(null);
+        setLoaded(true, 'root', 2);
+
+        if (appInstallationRepos.total_count === 0) {
+          setItems([]);
+        } else {
+          setItems([
+            {
+              parentId: null,
+              id: appInstallationRepos.repositories[0].owner.login,
+              title: appInstallationRepos.repositories[0].owner.login,
+              type: 'org',
+            } as any,
+          ])
+        }
+      } else {
+        const user = await API.getUser(prefix);
+        const orgs = await API.getUserOrgs(prefix, {
+          page: 1,
+          per_page: DEFAULT_PAGE_SIZE,
+        });
+
+        const loaded = !orgs.length || orgs.length < DEFAULT_PAGE_SIZE;
+
+        setUser(user);
+        setLoaded(loaded, 'root', 2);
+        setItems([
+          {
+            parentId: null,
+            id: user.login,
+            title: user.login,
+            type: 'org',
+          },
+          ...formatOrgs(orgs),
+        ]);
+    }
     })();
   }, [prefix]);
 
   const onExpand = useCallback(
     async (id: McsID) => {
       const item = items.find((it) => it.id === id) as McsItem<ExtraType>;
-
-      const isUser = id === user.login;
-      const repos = isUser
-        ? await API.getUserRepos(prefix, {
-            page: 1,
-            per_page: DEFAULT_PAGE_SIZE,
-          })
-        : await API.getOrgRepos(prefix, item.title, {
-            page: 1,
-            per_page: DEFAULT_PAGE_SIZE,
-          });
+      let repos = [];
+
+      if (user && id === user.login) {
+        repos = await API.getUserRepos(prefix, {
+          page: 1,
+          per_page: DEFAULT_PAGE_SIZE,
+        });
+      } else if (user) {
+        repos = await API.getOrgRepos(prefix, item.title, {
+          page: 1,
+          per_page: DEFAULT_PAGE_SIZE,
+        });
+      } else {
+        const response = await API.getInstallationRepos(prefix, {
+          page: 1,
+          per_page: DEFAULT_PAGE_SIZE,
+        });
+        repos = response.repositories;
+      }
 
       const loaded = !repos.length || repos.length < DEFAULT_PAGE_SIZE;
       setLoaded(loaded, id, 2);
@@ -134,18 +168,25 @@ export const useMillerColumns = ({ connectionId }: 
UseMillerColumnsProps) => {
     let loaded = false;
 
     if (id) {
-      const isUser = id === user.login;
       const item = items.find((it) => it.id === id) as McsItem<ExtraType>;
 
-      repos = isUser
-        ? await API.getUserRepos(prefix, {
-            page,
-            per_page: DEFAULT_PAGE_SIZE,
-          })
-        : await API.getOrgRepos(prefix, item.title, {
-            page,
-            per_page: DEFAULT_PAGE_SIZE,
-          });
+      if (user && id === user.login) {
+        repos = await API.getUserRepos(prefix, {
+          page: 1,
+          per_page: DEFAULT_PAGE_SIZE,
+        });
+      } else if (user) {
+        repos = await API.getOrgRepos(prefix, item.title, {
+          page: 1,
+          per_page: DEFAULT_PAGE_SIZE,
+        });
+      } else {
+        const response = await API.getInstallationRepos(prefix, {
+          page: 1,
+          per_page: DEFAULT_PAGE_SIZE,
+        });
+        repos = response.repositories;
+      }
 
       loaded = !repos.length || repos.length < DEFAULT_PAGE_SIZE;
     } else {
diff --git a/config-ui/src/plugins/register/github/config.tsx 
b/config-ui/src/plugins/register/github/config.tsx
index 85698f4bd..43a9db085 100644
--- a/config-ui/src/plugins/register/github/config.tsx
+++ b/config-ui/src/plugins/register/github/config.tsx
@@ -18,9 +18,10 @@
 
 import type { PluginConfigType } from '../../types';
 import { PluginType } from '../../types';
+import { pick } from 'lodash';
 
 import Icon from './assets/icon.svg';
-import { Token, Graphql } from './connection-fields';
+import { Token, Graphql, GithubApp, Authentication } from 
'./connection-fields';
 
 export const GitHubConfig: PluginConfigType = {
   type: PluginType.Connection,
@@ -32,6 +33,7 @@ export const GitHubConfig: PluginConfigType = {
     docLink: 'https://devlake.apache.org/docs/Configuration/GitHub',
     initialValues: {
       endpoint: 'https://api.github.com/',
+      authMethod: 'AccessToken',
       enableGraphql: true,
     },
     fields: [
@@ -44,17 +46,35 @@ export const GitHubConfig: PluginConfigType = {
         },
       },
       ({ initialValues, values, errors, setValues, setErrors }: any) => (
-        <Token
-          key="token"
-          endpoint={values.endpoint}
-          proxy={values.proxy}
-          initialValue={initialValues.token ?? ''}
-          value={values.token ?? ''}
-          error={errors.token ?? ''}
-          setValue={(value) => setValues({ token: value })}
-          setError={(value) => setErrors({ token: value })}
+        <Authentication
+          key="authMethod"
+          initialValue={initialValues.authMethod ?? ''}
+          value={values.authMethod ?? ''}
+          setValue={(value) => setValues({ authMethod: value })}
         />
       ),
+      ({ initialValues, values, errors, setValues, setErrors }: any) =>
+        (values.authMethod || initialValues.authMethod) == 'AccessToken' ? (
+          <Token
+            endpoint={values.endpoint}
+            proxy={values.proxy}
+            initialValue={initialValues.token ?? ''}
+            value={values.token ?? ''}
+            error={errors.token ?? ''}
+            setValue={(value) => setValues({ token: value })}
+            setError={(value) => setErrors({ token: value })}
+          />
+        ) : (
+          <GithubApp
+            endpoint={values.endpoint}
+            proxy={values.proxy}
+            initialValue={initialValues ? pick(initialValues, ['appId', 
'secretKey', 'installationId']) : {}}
+            value={values ? pick(values, ['appId', 'secretKey', 
'installationId']) : {}}
+            error={errors ?? {}}
+            setValue={(value) => setValues(value)}
+            setError={(value) => setErrors(value)}
+          />
+        ),
       'proxy',
       ({ initialValues, values, setValues }: any) => (
         <Graphql
diff --git a/config-ui/src/store/connections/api.ts 
b/config-ui/src/plugins/register/github/connection-fields/authentication.tsx
similarity index 50%
copy from config-ui/src/store/connections/api.ts
copy to 
config-ui/src/plugins/register/github/connection-fields/authentication.tsx
index d84d04852..8ddb6547d 100644
--- a/config-ui/src/store/connections/api.ts
+++ b/config-ui/src/plugins/register/github/connection-fields/authentication.tsx
@@ -16,32 +16,33 @@
  *
  */
 
-import { request } from '@/utils';
+import React, { useEffect } from 'react';
+import { FormGroup, RadioGroup, Radio } from '@blueprintjs/core';
 
-type GetConnectionRes = {
-  id: ID;
-  name: string;
-  endpoint: string;
-  proxy: string;
-  token?: string;
-  username?: string;
-  password?: string;
-  authMethod?: string;
-};
+import * as S from './styled';
 
-export const getConnection = (plugin: string): Promise<GetConnectionRes[]> => 
request(`/plugins/${plugin}/connections`);
+interface Props {
+  initialValue: string;
+  value: string;
+  setValue: (value: string) => void;
+}
 
-type TestConnectionPayload = {
-  endpoint: string;
-  proxy: string;
-  token?: string;
-  username?: string;
-  password?: string;
-  authMethod?: string;
-};
+export const Authentication = ({ initialValue, value, setValue }: Props) => {
 
-export const testConnection = (plugin: string, data: TestConnectionPayload) =>
-  request(`/plugins/${plugin}/test`, {
-    method: 'post',
-    data,
-  });
+  useEffect(() => {
+    setValue(initialValue);
+  }, [initialValue]);
+
+  return (
+    <FormGroup label={<S.Label>Authentication type</S.Label>} 
labelInfo={<S.LabelInfo>*</S.LabelInfo>}>
+      <RadioGroup inline selectedValue={value || initialValue} onChange={(e) 
=> {
+        setValue((e.target as any).value);
+      }}>
+        <Radio value="AccessToken">Github Access Token</Radio>
+        <Radio value="AppKey">
+          Github App
+        </Radio>
+      </RadioGroup>
+    </FormGroup>
+  );
+};
diff --git 
a/config-ui/src/plugins/register/github/connection-fields/githubapp.tsx 
b/config-ui/src/plugins/register/github/connection-fields/githubapp.tsx
new file mode 100644
index 000000000..6691c9025
--- /dev/null
+++ b/config-ui/src/plugins/register/github/connection-fields/githubapp.tsx
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React, { useEffect, useState } from 'react';
+import { Button, FormGroup, InputGroup, MenuItem, TextArea } from 
'@blueprintjs/core';
+import { Select2 } from '@blueprintjs/select';
+
+import { ExternalLink } from '@/components';
+
+import * as API from '../api';
+
+import * as S from './styled';
+
+interface Props {
+  endpoint?: string;
+  proxy?: string;
+  initialValue: any;
+  value: any;
+  error: string;
+  setValue: (value: any) => void;
+  setError: (error: any) => void;
+}
+
+interface GithubAppSettings {
+  appId?: string;
+  secretKey?: string;
+  installationId?: number;
+
+  status: 'idle' | 'valid' | 'invalid';
+  from?: string;
+  installations?: GithubInstallation[];
+}
+
+interface GithubInstallation {
+  id: number;
+  account: {
+    login: string;
+  };
+}
+
+export const GithubApp = ({ endpoint, proxy, initialValue, value, error, 
setValue, setError }: Props) => {
+  const [settings, setSettings] = useState<GithubAppSettings>({ status: 'idle' 
});
+
+  useEffect(() => {
+    setError({
+      appId: value.appId ? '' : 'AppId is required',
+      secretKey: value.secretKey ? '' : 'SecretKey is required',
+      installationId: value.installationId ? '' : 'InstallationId is required',
+    });
+
+    return () => {
+      setError({
+        appId: '',
+        secretKey: '',
+        installationId: '',
+      });
+    }
+  }, [value.appId, value.secretKey, value.installationId]);
+
+  const testConfiguration = async (appId?: string, secretKey?: string, 
installationId?: number): Promise<GithubAppSettings> => {
+    if (!endpoint || !appId || !secretKey) {
+      return {
+        appId,
+        secretKey,
+        installationId,
+        status: 'idle',
+      };
+    }
+
+    try {
+      const res = await API.testConnection({
+        authMethod: 'AppKey',
+        endpoint,
+        proxy,
+        appId,
+        secretKey,
+        token: '',
+      });
+      return {
+        appId,
+        secretKey,
+        installationId,
+        status: 'valid',
+        from: res.login,
+        installations: res.installations,
+      };
+    } catch {
+      return {
+        appId,
+        secretKey,
+        installationId,
+        status: 'invalid',
+      };
+    }
+  };
+
+  const handleChangeAppId = (value: string) => {
+    setSettings({ ...settings, appId: value });;
+  };
+
+  const handleChangeClientSecret = (value: string) => {
+    setSettings({ ...settings, secretKey: value });
+  };
+
+  const handleTestConfiguration = async () => {
+    const res = await testConfiguration(settings.appId, settings.secretKey, 
settings.installationId);
+    setSettings(res);
+  };
+
+  const checkConfig = async (appId: string, secretKey: string, installationId: 
number) => {
+    const res = await testConfiguration(appId, secretKey, installationId);
+    setSettings(res);
+  };
+
+
+  useEffect(() => {
+    checkConfig(initialValue.appId, initialValue.secretKey, 
initialValue.installationId);
+  }, [initialValue.appId, initialValue.secretKey, initialValue.installationId, 
endpoint]);
+
+  useEffect(() => {
+    setValue({ appId: settings.appId, secretKey: settings.secretKey, 
installationId: settings.installationId });
+  }, [settings.appId, settings.secretKey, settings.installationId]);
+
+
+  return (
+    <FormGroup
+      label={<S.Label>Github App settings</S.Label>}
+      labelInfo={<S.LabelInfo>*</S.LabelInfo>}
+      subLabel={
+        <S.LabelDescription>
+          Input information about your Github App{' '}
+          <ExternalLink link="https://TODO";>
+            Learn how to create a github app
+          </ExternalLink>
+        </S.LabelDescription>
+      }
+    >
+      <S.Input>
+        <div className="input">
+          <InputGroup
+            placeholder="App Id"
+            type="text"
+            value={settings.appId ?? ''}
+            onChange={(e) => handleChangeAppId(e.target.value)}
+            onBlur={() => handleTestConfiguration()}
+          />
+          <div className="info">
+            {settings.status === 'invalid' && <span 
className="error">Invalid</span>}
+            {settings.status === 'valid' && <span className="success">Valid 
From: {settings.from}</span>}
+          </div>
+        </div>
+      </S.Input>
+      <S.Input>
+        <div className="input">
+          <TextArea
+            cols={90}
+            rows={15}
+            placeholder="Private key"
+            value={settings.secretKey ?? ''}
+            onChange={(e) => handleChangeClientSecret(e.target.value)}
+            onBlur={() => handleTestConfiguration()}
+          />
+          <div className="info">
+            {settings.status === 'invalid' && <span 
className="error">Invalid</span>}
+            {settings.status === 'valid' && <span className="success">Valid 
From: {settings.from}</span>}
+          </div>
+        </div>
+      </S.Input>
+      <S.Input>
+        <Select2
+          items={settings.installations ?? []}
+          activeItem={settings.installations?.find(e => e.id === 
settings.installationId)}
+          itemPredicate={(query, item) => 
item.account.login.toLowerCase().includes(query.toLowerCase())}
+          itemRenderer={(item, { handleClick, handleFocus, modifiers }) => {
+            return (
+              <MenuItem
+                active={modifiers.active}
+                disabled={modifiers.disabled}
+                key={item.id}
+                label={item.id.toString()}
+                onClick={handleClick}
+                onFocus={handleFocus}
+                roleStructure="listoption"
+                text={item.account.login}
+            />
+            );
+          }}
+          onItemSelect={(item) => {
+            setSettings({ ...settings, installationId: item.id });
+          }}
+          noResults={<option disabled={true}>No results</option>}
+          popoverProps={{ minimal: true }}
+        >
+          <Button
+            text={settings.installations?.find(e => e.id === 
settings.installationId)?.account.login ?? 'Select App installation'}
+            rightIcon="double-caret-vertical"
+            placeholder="Select App installation" />
+        </Select2>
+      </S.Input>
+    </FormGroup>
+  );
+};
diff --git a/config-ui/src/plugins/register/github/connection-fields/index.ts 
b/config-ui/src/plugins/register/github/connection-fields/index.ts
index fc074058f..8e0a150a7 100644
--- a/config-ui/src/plugins/register/github/connection-fields/index.ts
+++ b/config-ui/src/plugins/register/github/connection-fields/index.ts
@@ -18,3 +18,5 @@
 
 export * from './token';
 export * from './graphql';
+export * from './githubapp';
+export * from './authentication';
\ No newline at end of file
diff --git a/config-ui/src/plugins/register/github/connection-fields/styled.ts 
b/config-ui/src/plugins/register/github/connection-fields/styled.ts
index a4119ee73..757d449c2 100644
--- a/config-ui/src/plugins/register/github/connection-fields/styled.ts
+++ b/config-ui/src/plugins/register/github/connection-fields/styled.ts
@@ -38,7 +38,7 @@ export const Endpoint = styled.div`
   }
 `;
 
-export const Token = styled.div`
+export const Input = styled.div`
   margin-bottom: 8px;
 
   .input {
diff --git a/config-ui/src/plugins/register/github/connection-fields/token.tsx 
b/config-ui/src/plugins/register/github/connection-fields/token.tsx
index a42d4bc26..b811a072d 100644
--- a/config-ui/src/plugins/register/github/connection-fields/token.tsx
+++ b/config-ui/src/plugins/register/github/connection-fields/token.tsx
@@ -55,6 +55,7 @@ export const Token = ({ endpoint, proxy, initialValue, value, 
error, setValue, s
 
     try {
       const res = await API.testConnection({
+        authMethod: 'AccessToken',
         endpoint,
         proxy,
         token,
@@ -84,6 +85,10 @@ export const Token = ({ endpoint, proxy, initialValue, 
value, error, setValue, s
 
   useEffect(() => {
     setError(value ? '' : 'token is required');
+
+    return () => {
+      setError('');
+    }
   }, [value]);
 
   useEffect(() => {
@@ -120,7 +125,7 @@ export const Token = ({ endpoint, proxy, initialValue, 
value, error, setValue, s
       }
     >
       {tokens.map(({ value, status, from, message }, i) => (
-        <S.Token key={i}>
+        <S.Input key={i}>
           <div className="input">
             <InputGroup
               placeholder="Token"
@@ -141,7 +146,7 @@ export const Token = ({ endpoint, proxy, initialValue, 
value, error, setValue, s
               {message}
             </div>
           )}
-        </S.Token>
+        </S.Input>
       ))}
       <div className="action">
         <Button outlined small intent={Intent.PRIMARY} text="Another Token" 
icon="plus" onClick={handleCreateToken} />
diff --git a/config-ui/src/store/connections/api.ts 
b/config-ui/src/store/connections/api.ts
index d84d04852..b7c443272 100644
--- a/config-ui/src/store/connections/api.ts
+++ b/config-ui/src/store/connections/api.ts
@@ -38,6 +38,8 @@ type TestConnectionPayload = {
   username?: string;
   password?: string;
   authMethod?: string;
+  appId?: string;
+  secretKey?: string;
 };
 
 export const testConnection = (plugin: string, data: TestConnectionPayload) =>
diff --git a/config-ui/src/store/connections/context.tsx 
b/config-ui/src/store/connections/context.tsx
index e0b03adc8..b9a1fcea0 100644
--- a/config-ui/src/store/connections/context.tsx
+++ b/config-ui/src/store/connections/context.tsx
@@ -70,6 +70,8 @@ export const ConnectionContextProvider = ({ children, 
...props }: Props) => {
     username,
     password,
     authMethod,
+    secretKey,
+    appId,
   }: ConnectionItemType) => {
     try {
       const res = await API.testConnection(plugin, {
@@ -79,6 +81,8 @@ export const ConnectionContextProvider = ({ children, 
...props }: Props) => {
         username,
         password,
         authMethod,
+        secretKey,
+        appId,
       });
       return res.success ? ConnectionStatusEnum.ONLINE : 
ConnectionStatusEnum.OFFLINE;
     } catch {
@@ -103,9 +107,13 @@ export const ConnectionContextProvider = ({ children, 
...props }: Props) => {
       username: it.username,
       password: it.password,
       authMethod: it.authMethod,
+      secretKey: it.secretKey,
+      appId: it.appId,
     }));
   };
 
+
+
   const handleGet = (unique: string) => {
     return connections.find((cs) => cs.unique === unique) as 
ConnectionItemType;
   };
diff --git a/config-ui/src/store/connections/types.ts 
b/config-ui/src/store/connections/types.ts
index cbf96b32a..68847166f 100644
--- a/config-ui/src/store/connections/types.ts
+++ b/config-ui/src/store/connections/types.ts
@@ -39,4 +39,6 @@ export type ConnectionItemType = {
   username?: string;
   password?: string;
   authMethod?: string;
+  appId?: string;
+  secretKey?: string;
 };


Reply via email to