diff --git a/app/client/workspace/workspace_service.go b/app/client/workspace/workspace_service.go index 25df3a43..1b5a432a 100644 --- a/app/client/workspace/workspace_service.go +++ b/app/client/workspace/workspace_service.go @@ -21,6 +21,55 @@ type WorkspaceServiceClient struct { slackAlert *monitoring.SlackAlert } +func (ws *WorkspaceServiceClient) ImportGitRepository(importRepository *request.ImportGitRepository) (createWorkspaceResponse *response.CreateWorkspaceResponse, err error) { + payload, err := json.Marshal(importRepository) + if err != nil { + log.Printf("failed to marshal import git repository request: %v", err) + return + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/workspaces/import", ws.endpoint), bytes.NewBuffer(payload)) + if err != nil { + log.Printf("failed to create import git repository request: %v", err) + return + } + + res, err := ws.client.Do(req) + if err != nil { + log.Printf("failed to send import git repository request: %v", err) + return + } + + if res.StatusCode < 200 || res.StatusCode > 299 { + err := ws.slackAlert.SendAlert(fmt.Sprintf("failed to import git repository: %s", res.Status), map[string]string{ + "workspace_id": importRepository.WorkspaceId, + "repository": importRepository.Repository, + }) + if err != nil { + log.Printf("failed to send slack alert: %v", err) + return nil, err + } + return nil, errors.New(fmt.Sprintf("invalid res from workspace service for import git repository request")) + } + + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(res.Body) + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + log.Printf("failed to read res payload: %v", err) + return + } + + createWorkspaceResponse = &response.CreateWorkspaceResponse{} + if err = json.Unmarshal(responseBody, &createWorkspaceResponse); err != nil { + log.Printf("failed to unmarshal create workspace res: %v", err) + return + } + return +} + func (ws *WorkspaceServiceClient) CreateWorkspace(createWorkspaceRequest *request.CreateWorkspaceRequest) (createWorkspaceResponse *response.CreateWorkspaceResponse, err error) { payload, err := json.Marshal(createWorkspaceRequest) if err != nil { diff --git a/app/config/config.go b/app/config/config.go index 87c5e809..9bad4c45 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -48,7 +48,7 @@ func LoadConfig() (*koanf.Koanf, error) { return config, err } -// Get returns the value for a given key. +// Deprecated: This is a misuse of the config package. func Get(key string) interface{} { return config.Get(key) } diff --git a/app/config/github_integration_config.go b/app/config/github_integration_config.go new file mode 100644 index 00000000..609412a5 --- /dev/null +++ b/app/config/github_integration_config.go @@ -0,0 +1,23 @@ +package config + +import "github.com/knadh/koanf/v2" + +type GithubIntegrationConfig struct { + config *koanf.Koanf +} + +func (gic *GithubIntegrationConfig) GetClientID() string { + return config.String("github.integration.client.id") +} + +func (gic *GithubIntegrationConfig) GetClientSecret() string { + return config.String("github.integration.client.secret") +} + +func (gic *GithubIntegrationConfig) GetRedirectURL() string { + return config.String("github.integration.client.redirecturl") +} + +func NewGithubIntegrationConfig(config *koanf.Koanf) *GithubIntegrationConfig { + return &GithubIntegrationConfig{config} +} diff --git a/app/config/logger.go b/app/config/logger.go index 546edbb8..475af75a 100644 --- a/app/config/logger.go +++ b/app/config/logger.go @@ -5,9 +5,9 @@ import "go.uber.org/zap" var Logger *zap.Logger func InitLogger() { - var err error - Logger, err = zap.NewProduction() - if err != nil { - panic(err) + if AppEnv() == "development" { + Logger, _ = zap.NewDevelopment(zap.IncreaseLevel(zap.DebugLevel)) + } else { + Logger, _ = zap.NewProduction() } } diff --git a/app/controllers/github_integration_controller.go b/app/controllers/github_integration_controller.go new file mode 100644 index 00000000..d1448229 --- /dev/null +++ b/app/controllers/github_integration_controller.go @@ -0,0 +1,88 @@ +package controllers + +import ( + "ai-developer/app/config" + "ai-developer/app/services/integrations" + "fmt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "net/http" +) + +type GithubIntegrationController struct { + githubIntegrationService *integrations.GithubIntegrationService + logger *zap.Logger +} + +func (gic *GithubIntegrationController) Authorize(c *gin.Context) { + userId, _ := c.Get("user_id") + gic.logger.Debug( + "Authorizing github integration", + zap.Any("user_id", userId), + ) + authCodeUrl := gic.githubIntegrationService.GetRedirectUrl(uint64(userId.(int))) + c.Redirect(http.StatusTemporaryRedirect, authCodeUrl) +} + +func (gic *GithubIntegrationController) DeleteIntegration(c *gin.Context) { + userId, _ := c.Get("user_id") + err := gic.githubIntegrationService.DeleteIntegration(uint64(userId.(int))) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Integration deleted successfully"}) +} + +func (gic *GithubIntegrationController) CheckIfIntegrationExists(c *gin.Context) { + userId, _ := c.Get("user_id") + hasIntegration, err := gic.githubIntegrationService.HasGithubIntegration(uint64(userId.(int))) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"integrated": hasIntegration}) +} + +func (gic *GithubIntegrationController) GetRepositories(c *gin.Context) { + userId, _ := c.Get("user_id") + repositories, err := gic.githubIntegrationService.GetRepositories(uint64(userId.(int))) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + response := make([]map[string]interface{}, 0) + for _, repo := range repositories { + response = append(response, map[string]interface{}{ + "id": repo.GetID(), + "url": repo.GetCloneURL(), + "name": repo.GetFullName(), + }) + } + c.JSON(http.StatusOK, gin.H{"repositories": response}) +} + +func (gic *GithubIntegrationController) HandleCallback(c *gin.Context) { + code := c.Query("code") + state := c.Query("state") + + gic.logger.Debug( + "Handling github integration callback", + zap.String("code", code), + zap.String("state", state), + ) + + _ = gic.githubIntegrationService.GenerateAndSaveAccessToken(code, state) + redirectUrl := fmt.Sprintf("%s/settings?page=integrations", config.GithubFrontendURL()) + c.Redirect(http.StatusTemporaryRedirect, redirectUrl) +} + +func NewGithubIntegrationController( + githubIntegrationService *integrations.GithubIntegrationService, + logger *zap.Logger, +) *GithubIntegrationController { + return &GithubIntegrationController{ + githubIntegrationService: githubIntegrationService, + logger: logger, + } +} diff --git a/app/controllers/project_controller.go b/app/controllers/project_controller.go index 0405523a..8d9e46ab 100644 --- a/app/controllers/project_controller.go +++ b/app/controllers/project_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "ai-developer/app/models" "ai-developer/app/services" "ai-developer/app/types/request" "net/http" @@ -72,7 +73,14 @@ func (controller *ProjectController) CreateProject(context *gin.Context) { context.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "User not found"}) return } - project, err := controller.projectService.CreateProject(int(user.OrganisationID), createProjectRequest) + + var project *models.Project + if createProjectRequest.Repository == nil { + project, err = controller.projectService.CreateProject(int(user.OrganisationID), createProjectRequest) + } else { + project, err = controller.projectService.CreateProjectFromGit(user.ID, user.OrganisationID, createProjectRequest) + } + if err != nil { context.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/app/db/migrations/20240718105113_create_integrations_table.down.sql b/app/db/migrations/20240718105113_create_integrations_table.down.sql new file mode 100644 index 00000000..f96a9bed --- /dev/null +++ b/app/db/migrations/20240718105113_create_integrations_table.down.sql @@ -0,0 +1 @@ +DROP TABLE integrations; \ No newline at end of file diff --git a/app/db/migrations/20240718105113_create_integrations_table.up.sql b/app/db/migrations/20240718105113_create_integrations_table.up.sql new file mode 100644 index 00000000..0166be53 --- /dev/null +++ b/app/db/migrations/20240718105113_create_integrations_table.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE integrations +( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + user_id BIGINT NOT NULL, + integration_type VARCHAR(255) NOT NULL, + access_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(255), + metadata JSONB +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_integration ON integrations (user_id, integration_type); +CREATE INDEX IF NOT EXISTS idx_user ON integrations (user_id); \ No newline at end of file diff --git a/app/db/migrations/20240723060635_add_repository_column_to_projects.down.sql b/app/db/migrations/20240723060635_add_repository_column_to_projects.down.sql new file mode 100644 index 00000000..56505832 --- /dev/null +++ b/app/db/migrations/20240723060635_add_repository_column_to_projects.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE projects DROP repository; +ALTER TABLE projects DROP repository_url; \ No newline at end of file diff --git a/app/db/migrations/20240723060635_add_repository_column_to_projects.up.sql b/app/db/migrations/20240723060635_add_repository_column_to_projects.up.sql new file mode 100644 index 00000000..82d2b3f4 --- /dev/null +++ b/app/db/migrations/20240723060635_add_repository_column_to_projects.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE projects ADD repository VARCHAR(1024); +ALTER TABLE projects ADD repository_url VARCHAR(1024); \ No newline at end of file diff --git a/app/models/dtos/integrations/github.go b/app/models/dtos/integrations/github.go new file mode 100644 index 00000000..766b1eb3 --- /dev/null +++ b/app/models/dtos/integrations/github.go @@ -0,0 +1,8 @@ +package integrations + +type GithubIntegrationDetails struct { + UserId uint64 + GithubUserId string + AccessToken string + RefreshToken *string +} diff --git a/app/models/integration.go b/app/models/integration.go new file mode 100644 index 00000000..09dbf561 --- /dev/null +++ b/app/models/integration.go @@ -0,0 +1,21 @@ +package models + +import ( + "ai-developer/app/models/types" + "gorm.io/gorm" +) + +type Integration struct { + *gorm.Model + ID uint `gorm:"primaryKey, autoIncrement"` + + UserId uint64 `gorm:"column:user_id;not null"` + User User `gorm:"foreignKey:UserId;uniqueIndex:idx_user_integration"` + + IntegrationType string `gorm:"column:integration_type;type:varchar(255);not null;uniqueIndex:idx_user_integration"` + + AccessToken string `gorm:"type:varchar(255);not null"` + RefreshToken *string `gorm:"type:varchar(255);null"` + + Metadata *types.JSONMap `gorm:"type:json;null"` +} diff --git a/app/models/project.go b/app/models/project.go index b4a5e88b..3fddab1f 100644 --- a/app/models/project.go +++ b/app/models/project.go @@ -13,6 +13,8 @@ type Project struct { Name string `gorm:"type:varchar(100);"` BackendFramework string `gorm:"type:varchar(100);not null"` FrontendFramework string `gorm:"type:varchar(100);not null"` + Repository *string `gorm:"type:varchar(1024);"` + RepositoryUrl *string `gorm:"type:varchar(1024);"` Description string `gorm:"type:text"` OrganisationID uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/app/repositories/integrations.go b/app/repositories/integrations.go new file mode 100644 index 00000000..6e82d5de --- /dev/null +++ b/app/repositories/integrations.go @@ -0,0 +1,87 @@ +package repositories + +import ( + "ai-developer/app/models" + "ai-developer/app/models/types" + "errors" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type IntegrationsRepository struct { + db *gorm.DB + logger *zap.Logger +} + +func (ir *IntegrationsRepository) FindIntegrationIdByUserIdAndType(userId uint64, integrationType string) (integration *models.Integration, err error) { + err = ir.db.Model(models.Integration{ + UserId: userId, + IntegrationType: integrationType, + }).First(&integration).Error + if err != nil { + return nil, err + } + return +} + +func (ir *IntegrationsRepository) DeleteIntegration(userId uint64, integrationType string) (err error) { + ir.logger.Info( + "Deleting integration", + zap.Uint64("userId", userId), + zap.String("integrationType", integrationType), + ) + err = ir.db.Unscoped().Where(&models.Integration{ + UserId: userId, + IntegrationType: integrationType, + }).Delete(&models.Integration{ + UserId: userId, + IntegrationType: integrationType, + }).Error + return +} + +func (ir *IntegrationsRepository) AddOrUpdateIntegration( + userId uint64, + integrationType string, + accessToken string, + refreshToken *string, + metadata *types.JSONMap, +) (err error) { + integration, err := ir.FindIntegrationIdByUserIdAndType(userId, integrationType) + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + if integration != nil { + ir.logger.Info( + "Updating integration", + zap.Uint64("userId", integration.UserId), + zap.String("integrationType", integration.IntegrationType), + ) + integration.AccessToken = accessToken + integration.RefreshToken = refreshToken + integration.Metadata = metadata + return ir.db.Save(integration).Error + } else { + integration = &models.Integration{ + UserId: userId, + IntegrationType: integrationType, + AccessToken: accessToken, + RefreshToken: refreshToken, + Metadata: metadata, + } + ir.logger.Info( + "Adding new integration", + zap.Uint64("userId", userId), + zap.String("integrationType", integrationType), + ) + return ir.db.Create(integration).Error + } +} + +func NewIntegrationsRepository(db *gorm.DB, logger *zap.Logger) *IntegrationsRepository { + return &IntegrationsRepository{ + db: db, + logger: logger.Named("IntegrationsRepository"), + } +} diff --git a/app/services/integrations/github_integration_service.go b/app/services/integrations/github_integration_service.go new file mode 100644 index 00000000..799f1bea --- /dev/null +++ b/app/services/integrations/github_integration_service.go @@ -0,0 +1,173 @@ +package integrations + +import ( + "ai-developer/app/config" + "ai-developer/app/models/dtos/integrations" + "ai-developer/app/utils" + "context" + "errors" + "fmt" + "github.com/google/go-github/github" + "go.uber.org/zap" + "golang.org/x/oauth2" + githubOAuth "golang.org/x/oauth2/github" + "gorm.io/gorm" + "strconv" +) + +type GithubIntegrationService struct { + logger *zap.Logger + + oauthConfig oauth2.Config + githubIntegrationConfig *config.GithubIntegrationConfig + + integrationService *IntegrationService +} + +func (gis *GithubIntegrationService) DeleteIntegration(userId uint64) (err error) { + err = gis.integrationService.DeleteIntegration(userId, GithubIntegrationType) + return +} + +func (gis *GithubIntegrationService) GetRedirectUrl(userId uint64) string { + return gis.oauthConfig.AuthCodeURL(fmt.Sprintf("%d", userId), oauth2.AccessTypeOnline) +} + +func (gis *GithubIntegrationService) HasGithubIntegration(userId uint64) (hasIntegration bool, err error) { + integration, err := gis.integrationService.FindIntegrationIdByUserIdAndType(userId, GithubIntegrationType) + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return + } + hasIntegration = integration != nil + return +} + +func (gis *GithubIntegrationService) GetRepositories(userId uint64) (repos []*github.Repository, err error) { + integration, err := gis.integrationService.FindIntegrationIdByUserIdAndType(userId, GithubIntegrationType) + if errors.Is(err, gorm.ErrRecordNotFound) { + return make([]*github.Repository, 0), nil + } + + if err != nil { + return + } + + client := github.NewClient(gis.oauthConfig.Client(context.Background(), &oauth2.Token{ + AccessToken: integration.AccessToken, + })) + + repos, _, err = client.Repositories.List(context.Background(), "", &github.RepositoryListOptions{ + ListOptions: github.ListOptions{ + PerPage: 500, + }, + }) + + if err != nil { + gis.logger.Error("Error getting github repositories", zap.Error(err)) + return + } + return +} + +func (gis *GithubIntegrationService) GetGithubIntegrationDetails(userId uint64) (integrationsDetails *integrations.GithubIntegrationDetails, err error) { + integration, err := gis.integrationService.FindIntegrationIdByUserIdAndType(userId, GithubIntegrationType) + if err != nil || integration == nil { + return + } + + client := github.NewClient(gis.oauthConfig.Client(context.Background(), &oauth2.Token{ + AccessToken: integration.AccessToken, + })) + githubUser, _, err := client.Users.Get(context.Background(), "") + if err != nil { + gis.logger.Error("Error getting github user", zap.Error(err)) + return + } + + integrationsDetails = &integrations.GithubIntegrationDetails{ + UserId: integration.UserId, + AccessToken: integration.AccessToken, + RefreshToken: integration.RefreshToken, + GithubUserId: githubUser.GetLogin(), + } + + return +} + +func (gis *GithubIntegrationService) GenerateAndSaveAccessToken(code string, state string) (err error) { + token, err := gis.oauthConfig.Exchange(context.Background(), code) + if err != nil { + gis.logger.Error("Error exchanging code for token", zap.Error(err)) + return + } + + userId, err := strconv.ParseUint(state, 10, 64) + if err != nil { + gis.logger.Error("Error parsing state to userId", zap.Error(err)) + return + } + + if userId == 0 { + gis.logger.Error("Invalid userId", zap.Uint64("userId", userId)) + return + } + + client := github.NewClient(gis.oauthConfig.Client(context.Background(), token)) + githubUser, _, err := client.Users.Get(context.Background(), "") + if err != nil { + gis.logger.Error("Error getting github user", zap.Error(err)) + return + } + + metadata, err := utils.GetAsJsonMap(githubUser) + if err != nil { + gis.logger.Error("Error getting github user as json map", zap.Error(err)) + return + } + + gis.logger.Info( + "Adding or updating github integration", + zap.Uint64("userId", userId), + zap.Any("metadata", metadata), + ) + + var refreshToken *string + if token.RefreshToken != "" { + refreshToken = &token.RefreshToken + } + + err = gis.integrationService.AddOrUpdateIntegration( + userId, + GithubIntegrationType, + token.AccessToken, + refreshToken, + metadata, + ) + + return +} + +func NewGithubIntegrationService( + logger *zap.Logger, + githubIntegrationConfig *config.GithubIntegrationConfig, + integrationService *IntegrationService, +) *GithubIntegrationService { + + oauthConfig := oauth2.Config{ + RedirectURL: githubIntegrationConfig.GetRedirectURL(), + ClientID: githubIntegrationConfig.GetClientID(), + ClientSecret: githubIntegrationConfig.GetClientSecret(), + Scopes: []string{"user:email", "repo"}, + Endpoint: githubOAuth.Endpoint, + } + + return &GithubIntegrationService{ + logger: logger.Named("GithubIntegrationService"), + oauthConfig: oauthConfig, + integrationService: integrationService, + githubIntegrationConfig: githubIntegrationConfig, + } +} diff --git a/app/services/integrations/integration_service.go b/app/services/integrations/integration_service.go new file mode 100644 index 00000000..2ead1c52 --- /dev/null +++ b/app/services/integrations/integration_service.go @@ -0,0 +1,47 @@ +package integrations + +import ( + "ai-developer/app/models" + "ai-developer/app/models/types" + "ai-developer/app/repositories" + "go.uber.org/zap" +) + +type IntegrationService struct { + integrationsRepository *repositories.IntegrationsRepository + logger *zap.Logger +} + +func (is *IntegrationService) FindIntegrationIdByUserIdAndType(userId uint64, integrationType string) (integration *models.Integration, err error) { + return is.integrationsRepository.FindIntegrationIdByUserIdAndType(userId, integrationType) +} + +func (is *IntegrationService) DeleteIntegration(userId uint64, integrationType string) (err error) { + return is.integrationsRepository.DeleteIntegration(userId, integrationType) +} + +func (is *IntegrationService) AddOrUpdateIntegration( + userId uint64, + integrationType string, + accessToken string, + refreshToken *string, + metadata *types.JSONMap, +) (err error) { + return is.integrationsRepository.AddOrUpdateIntegration( + userId, + integrationType, + accessToken, + refreshToken, + metadata, + ) +} + +func NewIntegrationService( + integrationsRepository *repositories.IntegrationsRepository, + logger *zap.Logger, +) *IntegrationService { + return &IntegrationService{ + integrationsRepository: integrationsRepository, + logger: logger.Named("IntegrationService"), + } +} diff --git a/app/services/integrations/integrations.go b/app/services/integrations/integrations.go new file mode 100644 index 00000000..c51c4028 --- /dev/null +++ b/app/services/integrations/integrations.go @@ -0,0 +1,5 @@ +package integrations + +const ( + GithubIntegrationType = "GITHUB" +) diff --git a/app/services/project_service.go b/app/services/project_service.go index d8d77f13..a3cc489e 100644 --- a/app/services/project_service.go +++ b/app/services/project_service.go @@ -8,6 +8,7 @@ import ( "ai-developer/app/models/dtos/asynq_task" "ai-developer/app/repositories" "ai-developer/app/services/git_providers" + "ai-developer/app/services/integrations" "ai-developer/app/types/request" "ai-developer/app/types/response" "ai-developer/app/utils" @@ -22,16 +23,17 @@ import ( ) type ProjectService struct { - redisRepo *repositories.ProjectConnectionsRepository - projectRepo *repositories.ProjectRepository - organisationRepository *repositories.OrganisationRepository - storyRepository *repositories.StoryRepository - pullRequestRepository *repositories.PullRequestRepository - gitnessService *git_providers.GitnessService - hashIdGenerator *utils.HashIDGenerator - workspaceServiceClient *workspace.WorkspaceServiceClient - asynqClient *asynq.Client - logger *zap.Logger + redisRepo *repositories.ProjectConnectionsRepository + projectRepo *repositories.ProjectRepository + organisationRepository *repositories.OrganisationRepository + storyRepository *repositories.StoryRepository + pullRequestRepository *repositories.PullRequestRepository + gitnessService *git_providers.GitnessService + hashIdGenerator *utils.HashIDGenerator + workspaceServiceClient *workspace.WorkspaceServiceClient + asynqClient *asynq.Client + githubIntegrationService *integrations.GithubIntegrationService + logger *zap.Logger } func (s *ProjectService) GetAllProjectsOfOrganisation(organisationId int) ([]response.GetAllProjectsResponse, error) { @@ -64,6 +66,7 @@ func (s *ProjectService) GetAllProjectsOfOrganisation(organisationId int) ([]res ProjectBackendURL: project.BackendURL, ProjectFrontendURL: project.FrontendURL, PullRequestCount: len(projectPullRequestMap[int(project.ID)]), + ProjectRepository: project.Repository, }) } @@ -82,6 +85,89 @@ func (s *ProjectService) GetProjectDetailsById(projectId int) (*models.Project, return project, nil } +func (s *ProjectService) CreateProjectFromGit(userId uint, orgId uint, requestData request.CreateProjectRequest) (project *models.Project, err error) { + integrationDetails, err := s.githubIntegrationService.GetGithubIntegrationDetails(uint64(userId)) + if err != nil { + s.logger.Error("Error getting github integration details", zap.Error(err)) + return nil, err + } + + hashID := s.hashIdGenerator.Generate() + "-" + uuid.New().String() + url := "http://localhost:8081/?folder=/workspaces/" + hashID + backend_url := "http://localhost:5000" + frontend_url := "http://localhost:3000" + env := config.Get("app.env") + host := config.Get("workspace.host") + if env == "production" { + url = fmt.Sprintf("https://%s.%s/?folder=/workspaces/%s", hashID, host, hashID) + backend_url = fmt.Sprintf("https://be-%s.%s", hashID, host) + frontend_url = fmt.Sprintf("https://fe-%s.%s", hashID, host) + } + project = &models.Project{ + OrganisationID: orgId, + Name: requestData.Name, + BackendFramework: requestData.Framework, + FrontendFramework: requestData.FrontendFramework, + Description: requestData.Description, + HashID: hashID, + Url: url, + BackendURL: backend_url, + FrontendURL: frontend_url, + Repository: requestData.Repository, + RepositoryUrl: requestData.RepositoryUrl, + } + + organisation, err := s.organisationRepository.GetOrganisationByID(uint(int(project.OrganisationID))) + spaceOrProjectName := s.gitnessService.GetSpaceOrProjectName(organisation) + repository, err := s.gitnessService.CreateRepository(spaceOrProjectName, project.Name, project.Description) + if err != nil { + s.logger.Error("Error creating repository", zap.Error(err)) + return nil, err + } + httpPrefix := "https" + + if config.AppEnv() == constants.Development { + httpPrefix = "http" + } + + remoteGitURL := fmt.Sprintf("%s://%s:%s@%s/git/%s/%s.git", httpPrefix, config.GitnessUser(), config.GitnessToken(), config.GitnessHost(), spaceOrProjectName, project.Name) + //Making Call to Workspace Service to create workspace on project level + _, err = s.workspaceServiceClient.ImportGitRepository( + &request.ImportGitRepository{ + WorkspaceId: hashID, + Repository: *requestData.RepositoryUrl, + Username: integrationDetails.GithubUserId, + Password: integrationDetails.AccessToken, + RemoteURL: remoteGitURL, + GitnessUser: config.GitnessUser(), + GitnessToken: config.GitnessToken(), + }, + ) + + if err != nil { + s.logger.Error("Error creating workspace", zap.Error(err)) + return nil, err + } + + //Enqueue job to delete workspace with updated delay + payloadBytes, err := json.Marshal(asynq_task.CreateDeleteWorkspaceTaskPayload{ + WorkspaceID: project.HashID, + }) + if err != nil { + s.logger.Error("Failed to marshal payload", zap.Error(err)) + return nil, err + } + _, err = s.asynqClient.Enqueue( + asynq.NewTask(constants.DeleteWorkspaceTaskType, payloadBytes), + asynq.ProcessIn(constants.ProjectConnectionTTL+10*time.Minute), + asynq.MaxRetry(3), + asynq.TaskID("delete:fallback:"+project.HashID), + ) + + s.logger.Info("Project created successfully with repository", zap.Any("project", project), zap.Any("repository", repository)) + return s.projectRepo.CreateProject(project) +} + func (s *ProjectService) CreateProject(organisationID int, requestData request.CreateProjectRequest) (*models.Project, error) { hashID := s.hashIdGenerator.Generate() + "-" + uuid.New().String() url := "http://localhost:8081/?folder=/workspaces/" + hashID @@ -314,21 +400,23 @@ func NewProjectService(projectRepo *repositories.ProjectRepository, storyRepository *repositories.StoryRepository, pullRequestRepository *repositories.PullRequestRepository, workspaceServiceClient *workspace.WorkspaceServiceClient, + githubIntegrationService *integrations.GithubIntegrationService, repo *repositories.ProjectConnectionsRepository, asynqClient *asynq.Client, logger *zap.Logger, ) *ProjectService { return &ProjectService{ - projectRepo: projectRepo, - gitnessService: gitnessService, - organisationRepository: organisationRepository, - storyRepository: storyRepository, - pullRequestRepository: pullRequestRepository, - workspaceServiceClient: workspaceServiceClient, - redisRepo: repo, - hashIdGenerator: utils.NewHashIDGenerator(5), - logger: logger.Named("ProjectService"), - asynqClient: asynqClient, + projectRepo: projectRepo, + gitnessService: gitnessService, + organisationRepository: organisationRepository, + storyRepository: storyRepository, + pullRequestRepository: pullRequestRepository, + workspaceServiceClient: workspaceServiceClient, + redisRepo: repo, + hashIdGenerator: utils.NewHashIDGenerator(5), + logger: logger.Named("ProjectService"), + asynqClient: asynqClient, + githubIntegrationService: githubIntegrationService, } } diff --git a/app/types/request/create_project_request.go b/app/types/request/create_project_request.go index 1024434b..f725d228 100644 --- a/app/types/request/create_project_request.go +++ b/app/types/request/create_project_request.go @@ -1,8 +1,10 @@ package request type CreateProjectRequest struct { - Name string `json:"name"` - Framework string `json:"framework"` - FrontendFramework string `json:"frontend_framework"` - Description string `json:"description"` + Name string `json:"name"` + Framework string `json:"framework"` + FrontendFramework string `json:"frontend_framework"` + Description string `json:"description"` + Repository *string `json:"repository"` + RepositoryUrl *string `json:"repository_url"` } diff --git a/app/types/request/import_github_repository.go b/app/types/request/import_github_repository.go new file mode 100644 index 00000000..643d8184 --- /dev/null +++ b/app/types/request/import_github_repository.go @@ -0,0 +1,13 @@ +package request + +type ImportGitRepository struct { + WorkspaceId string `json:"workspaceId"` + + Repository string `json:"repository"` + Username string `json:"username"` + Password string `json:"password"` + + RemoteURL string `json:"remoteURL"` + GitnessUser string `json:"gitnessUser"` + GitnessToken string `json:"gitnessToken"` +} diff --git a/app/types/response/get_all_projects_response.go b/app/types/response/get_all_projects_response.go index e8036270..39124b64 100644 --- a/app/types/response/get_all_projects_response.go +++ b/app/types/response/get_all_projects_response.go @@ -1,14 +1,15 @@ package response type GetAllProjectsResponse struct { - ProjectId uint `json:"project_id"` - ProjectName string `json:"project_name"` - ProjectDescription string `json:"project_description"` - ProjectFramework string `json:"project_framework"` - ProjectFrontendFramework string `json:"project_frontend_framework"` - ProjectHashID string `json:"project_hash_id"` - ProjectUrl string `json:"project_url"` - ProjectBackendURL string `json:"project_backend_url"` - ProjectFrontendURL string `json:"project_frontend_url"` - PullRequestCount int `json:"pull_request_count"` + ProjectId uint `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectDescription string `json:"project_description"` + ProjectFramework string `json:"project_framework"` + ProjectFrontendFramework string `json:"project_frontend_framework"` + ProjectHashID string `json:"project_hash_id"` + ProjectUrl string `json:"project_url"` + ProjectBackendURL string `json:"project_backend_url"` + ProjectFrontendURL string `json:"project_frontend_url"` + PullRequestCount int `json:"pull_request_count"` + ProjectRepository *string `json:"project_repository"` } diff --git a/app/utils/json_utils.go b/app/utils/json_utils.go new file mode 100644 index 00000000..d1a6d42a --- /dev/null +++ b/app/utils/json_utils.go @@ -0,0 +1,22 @@ +package utils + +import ( + "ai-developer/app/config" + "ai-developer/app/models/types" + "encoding/json" + "go.uber.org/zap" +) + +func GetAsJsonMap(item interface{}) (jsonMap *types.JSONMap, err error) { + jsonBytes, err := json.Marshal(item) + if err != nil { + config.Logger.Error("Error marshalling item", zap.Error(err)) + return + } + err = json.Unmarshal(jsonBytes, &jsonMap) + if err != nil { + config.Logger.Error("Error unmarshalling item", zap.Error(err)) + return + } + return +} diff --git a/docker-compose.yaml b/docker-compose.yaml index c3ab13c4..70adbe9b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,6 +30,9 @@ services: AI_DEVELOPER_AWS_BUCKET_NAME: ${AI_DEVELOPER_AWS_BUCKET_NAME} AI_DEVELOPER_AWS_REGION: ${AI_DEVELOPER_AWS_REGION} AI_DEVELOPER_WORKSPACE_STATIC_FRONTEND_URL: "http://localhost:8083" + AI_DEVELOPER_GITHUB_INTEGRATION_CLIENT_ID: ${AI_DEVELOPER_GITHUB_INTEGRATION_CLIENT_ID:-} + AI_DEVELOPER_GITHUB_INTEGRATION_CLIENT_SECRET: ${AI_DEVELOPER_GITHUB_INTEGRATION_CLIENT_SECRET:-} + AI_DEVELOPER_GITHUB_INTEGRATION_CLIENT_REDIRECT_URL: ${AI_DEVELOPER_GITHUB_INTEGRATION_CLIENT_REDIRECT_URL:-} volumes: - workspaces:/workspaces - './startup.sh:/startup.sh' diff --git a/gui/package.json b/gui/package.json index 2aa1006d..b6ad796b 100644 --- a/gui/package.json +++ b/gui/package.json @@ -14,6 +14,7 @@ "@nextui-org/react": "^2.3.6", "axios": "^1.7.2", "babel-plugin-styled-components": "^2.1.4", + "chroma-js": "^2.4.2", "cookie": "^0.6.0", "diff2html": "^3.4.48", "framer-motion": "^11.2.4", @@ -21,6 +22,7 @@ "js-cookie": "^3.0.5", "monaco-editor": "^0.50.0", "next": "14.2.2", + "next-themes": "^0.3.0", "react": "^18", "react-beautiful-dnd": "^13.1.1", "react-diff-view": "^3.2.1", @@ -29,6 +31,7 @@ "react-dom": "^18", "react-hot-toast": "^2.4.1", "react-loading-skeleton": "^3.4.0", + "react-select": "^5.8.0", "react-syntax-highlighter": "^15.5.0", "react-transition-group": "^4.4.5", "sharp": "^0.33.4", @@ -36,6 +39,7 @@ "styled-components": "^6.1.11" }, "devDependencies": { + "@types/chroma-js": "^2.4.4", "@types/cookie": "^0.6.0", "@types/js-cookie": "^3.0.6", "@types/node": "^20", diff --git a/gui/public/logos/github_light_logo.svg b/gui/public/logos/github_light_logo.svg new file mode 100644 index 00000000..a3968743 --- /dev/null +++ b/gui/public/logos/github_light_logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/gui/src/api/DashboardService.tsx b/gui/src/api/DashboardService.tsx index 73e62071..77b5590f 100644 --- a/gui/src/api/DashboardService.tsx +++ b/gui/src/api/DashboardService.tsx @@ -5,8 +5,8 @@ import { } from '../../types/projectsTypes'; import { FormStoryPayload } from '../../types/storyTypes'; import { - CommentReBuildPayload, CommentReBuildDesignStoryPayload, + CommentReBuildPayload, CreatePullRequestPayload, } from '../../types/pullRequestsTypes'; import { CreateOrUpdateLLMAPIKeyPayload } from '../../types/modelsTypes'; @@ -180,3 +180,19 @@ export const rebuildDesignStory = ( export const updateReviewViewedStatus = (story_id: number) => { return api.put(`/stories/design/review_viewed/${story_id}`, {}); }; + +export const isGithubConnected = async () => { + const response = await api.get(`/integrations/github`); + const { integrated }: { integrated: boolean } = response.data; + return integrated; +}; +export const getGithubRepos = async () => { + const response = await api.get(`/integrations/github/repos`); + const { repositories } = response.data; + return repositories; +}; + +export const deleteGithubIntegration = async () => { + const response = await api.delete(`/integrations/github`); + return response.data; +}; diff --git a/gui/src/app/_app.css b/gui/src/app/_app.css index d4c35989..5767f7d9 100644 --- a/gui/src/app/_app.css +++ b/gui/src/app/_app.css @@ -28,6 +28,8 @@ :root { --foreground-rgb: #fff; --background-color: #000; + --border-color: #313134; + --container-background: #262626; --secondary-color: #888; --text-color: #fff; --error-color: #FF3E2D; diff --git a/gui/src/app/imagePath.tsx b/gui/src/app/imagePath.tsx index 52dbb489..76e1b16c 100644 --- a/gui/src/app/imagePath.tsx +++ b/gui/src/app/imagePath.tsx @@ -4,6 +4,7 @@ export default { superagiLogo: '/logos/superagi_logo.svg', superagiLogoRound: '/logos/superagi_logo_round.svg', supercoderImage: '/images/supercoder_image.svg', + githubLightLogo: '/logos/github_light_logo.svg', githubLogo: '/logos/github_logo.svg', frontendWorkbenchIconSelected: '/icons/selected/frontend_workbench.svg', backendWorkbenchIconSelected: '/icons/selected/backend_workbench.svg', diff --git a/gui/src/app/projects/layout.tsx b/gui/src/app/projects/layout.tsx index 9a322b9f..dc453550 100644 --- a/gui/src/app/projects/layout.tsx +++ b/gui/src/app/projects/layout.tsx @@ -1,7 +1,6 @@ import React, { ReactNode } from 'react'; import NavBar from '@/components/LayoutComponents/NavBar'; import styles from './projects.module.css'; -import { SocketProvider } from '@/context/SocketContext'; export default function ProjectsLayout({ children, diff --git a/gui/src/app/projects/page.tsx b/gui/src/app/projects/page.tsx index 07b27635..9e26bc40 100644 --- a/gui/src/app/projects/page.tsx +++ b/gui/src/app/projects/page.tsx @@ -11,6 +11,7 @@ import CreateOrEditProjectBody from '@/components/HomeComponents/CreateOrEditPro import CustomLoaders from '@/components/CustomLoaders/CustomLoaders'; import { SkeletonTypes } from '@/app/constants/SkeletonConstants'; import CustomImage from '@/components/ImageComponents/CustomImage'; +import Image from 'next/image'; export default function Projects() { const [openNewProjectModal, setOpenNewProjectModal] = useState< @@ -79,6 +80,20 @@ export default function Projects() {
+ {project.project_repository && ( +
+
+ +
+ + {project.project_repository.split('/').join(' / ')} + +
+ )} {children}; + return ( + + + {children} + + + ); } diff --git a/gui/src/app/settings/SettingsOptions/Integrations.tsx b/gui/src/app/settings/SettingsOptions/Integrations.tsx new file mode 100644 index 00000000..bc5c00e4 --- /dev/null +++ b/gui/src/app/settings/SettingsOptions/Integrations.tsx @@ -0,0 +1,80 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +import styles from '../settings.module.css'; +import { + deleteGithubIntegration, + isGithubConnected, +} from '@/api/DashboardService'; +import { API_BASE_URL } from '@/api/apiConfig'; +import GithubIntegrationSymbol from '@/components/IntegrationComponents/GithubIntegrationSymbol'; +import { Button } from '@nextui-org/react'; + +async function redirectToGithubIntegration() { + try { + window.location.href = `${API_BASE_URL}/integrations/github/authorize`; + } catch (error) { + console.error('Error: ', error); + } +} + +const Integrations = () => { + const [isExternalGitIntegration, setIsExternalGitIntegration] = useState< + boolean | undefined + >(null); + useEffect(() => { + (async function () { + const gitIntegrated = await isGithubConnected(); + setIsExternalGitIntegration(gitIntegrated); + })(); + }, []); + + const deleteIntegration = async () => { + await deleteGithubIntegration(); + const gitIntegrated = await isGithubConnected(); + setIsExternalGitIntegration(gitIntegrated); + }; + + return ( +
+
+

Integrations

+
+
+
+ +
+

GitHub

+

+ Integrate with GitHub to start working on your projects +

+
+
+ {isExternalGitIntegration === undefined ? ( + <> + ) : isExternalGitIntegration === false ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +export default Integrations; diff --git a/gui/src/app/settings/page.tsx b/gui/src/app/settings/page.tsx index f9d1c979..0816a2cd 100644 --- a/gui/src/app/settings/page.tsx +++ b/gui/src/app/settings/page.tsx @@ -4,6 +4,7 @@ import Models from '@/app/settings/SettingsOptions/Models'; import CustomSidebar from '@/components/CustomSidebar/CustomSidebar'; import imagePath from '@/app/imagePath'; import BackButton from '@/components/BackButton/BackButton'; +import Integrations from '@/app/settings/SettingsOptions/Integrations'; export default function Settings() { const options = [ @@ -15,6 +16,14 @@ export default function Settings() { icon_css: 'size-4', component: , }, + { + key: 'integrations', + text: 'Integrations', + selected: imagePath.modelsIconSelected, + unselected: imagePath.modelsIconUnselected, + icon_css: 'size-4', + component: , + }, ]; const handleOptionSelect = (key: string) => { diff --git a/gui/src/app/settings/settings.module.css b/gui/src/app/settings/settings.module.css new file mode 100644 index 00000000..ec69373a --- /dev/null +++ b/gui/src/app/settings/settings.module.css @@ -0,0 +1,3 @@ +.integration_container { + background-color: var(--container-background); +} \ No newline at end of file diff --git a/gui/src/components/CustomSidebar/CustomSidebar.tsx b/gui/src/components/CustomSidebar/CustomSidebar.tsx index 3fd8dc00..a9f847ed 100644 --- a/gui/src/components/CustomSidebar/CustomSidebar.tsx +++ b/gui/src/components/CustomSidebar/CustomSidebar.tsx @@ -1,8 +1,9 @@ 'use client'; -import React, { useState, useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import styles from './sidebar.module.css'; import CustomImage from '@/components/ImageComponents/CustomImage'; import { CustomSidebarProps } from '../../../types/customComponentTypes'; +import { useSearchParams } from 'next/navigation'; const CustomSidebar: React.FC = ({ id, @@ -10,7 +11,14 @@ const CustomSidebar: React.FC = ({ options, onOptionSelect, }) => { - const [selectedKey, setSelectedKey] = useState(options[0].key); + const searchParams = useSearchParams(); + const page = searchParams.get('page'); + const selectedPage = + options?.find((option) => option.key === page) || options[0]; + + const [selectedKey, setSelectedKey] = useState( + selectedPage.key, + ); const handleOptionClick = useCallback( (key: string) => { diff --git a/gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx b/gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx index 923ce321..c57c050f 100644 --- a/gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx +++ b/gui/src/components/HomeComponents/CreateOrEditProjectBody.tsx @@ -13,13 +13,19 @@ import { } from '../../../types/projectsTypes'; import { createProject, + getGithubRepos, getProjectById, + isGithubConnected, updateProject, } from '@/api/DashboardService'; import { useRouter } from 'next/navigation'; import { setProjectDetails } from '@/app/utils'; import CustomImage from '@/components/ImageComponents/CustomImage'; import CustomInput from '@/components/CustomInput/CustomInput'; +import styles from './create-project.module.css'; +import imagePath from '@/app/imagePath'; +import { API_BASE_URL } from '@/api/apiConfig'; +import Select from 'react-select'; interface CreateOrEditProjectBodyProps { id: string; @@ -29,6 +35,44 @@ interface CreateOrEditProjectBodyProps { edit?: boolean; } +const customStyles = { + control: (provided) => ({ + ...provided, + borderRadius: '0.5rem', + backgroundColor: '#1e1e1e', + color: '#ffffff', + borderColor: '#333333', + boxShadow: 'none', + '&:hover': { + color: '#ffffff', + borderColor: '#333333', + boxShadow: 'none', + }, + }), + indicatorSeparator: (provided) => ({}), + menu: (provided) => ({ + ...provided, + backgroundColor: '#1e1e1e', + }), + input: (styles) => ({ + ...styles, + color: '#ffffff', + }), + option: (provided, state) => ({ + ...provided, + backgroundColor: state.isFocused ? '#333333' : '#1e1e1e', + color: '#ffffff', + '&:hover': { + backgroundColor: '#2a2a2a', + color: '#ffffff', + }, + }), + singleValue: (provided) => ({ + ...provided, + color: '#ffffff', + }), +}; + export default function CreateOrEditProjectBody({ id, openProjectModal, @@ -48,6 +92,33 @@ export default function CreateOrEditProjectBody({ const projectIdRef = useRef(null); const router = useRouter(); + const [integrationLoading, setIntegrationLoading] = useState(false); + const [isExternalGitIntegration, setIsExternalGitIntegration] = + useState(false); + const [useExternalGit, setUseExternalGit] = useState(false); + const [repositories, setRepositories] = useState([]); + const [selectedRepository, setSelectedRepository] = useState<{ + label: string; + value: string; + } | null>(null); + + async function redirectToGithubIntegration() { + setIntegrationLoading(true); + try { + const interval = setInterval(async () => { + const gitIntegrated = await isGithubConnected(); + if (gitIntegrated) { + setIsExternalGitIntegration(true); + setIntegrationLoading(false); + clearInterval(interval); + } + }, 1000); + window.open(`${API_BASE_URL}/integrations/github/authorize`, '_blank'); + } catch (error) { + console.error('Error: ', error); + } + } + const handleProjectDuplicationCheck = () => { if (!projectsList) { return false; // or handle the case where projectsList is null @@ -66,6 +137,10 @@ export default function CreateOrEditProjectBody({ const handleCreateNewProject = async () => { setIsLoading(true); const projectErrors = [ + { + validation: selectedRepository === null && useExternalGit, + message: 'Please select a github repository to import.', + }, { validation: handleProjectDuplicationCheck(), message: 'A project with the name entered already exists.', @@ -101,11 +176,36 @@ export default function CreateOrEditProjectBody({ framework: selectedBackendFramework, frontend_framework: selectedFrontendFramework, description: projectDescription, + repository: useExternalGit ? selectedRepository.label : undefined, + repository_url: useExternalGit ? selectedRepository.value : undefined, }; await toCreateNewProject(newProjectPayload); } }; + useEffect(() => { + (async function () { + const repositories = await getGithubRepos(); + const options = repositories.map( + (repository: { name: string; url: string }) => { + const { name, url } = repository; + return { + value: url, + label: name, + }; + }, + ); + setRepositories(options); + })(); + }, [isExternalGitIntegration]); + + useEffect(() => { + (async function () { + const gitIntegrated = await isGithubConnected(); + setIsExternalGitIntegration(gitIntegrated); + })(); + }, []); + useEffect(() => { if (typeof window !== 'undefined') { projectIdRef.current = localStorage.getItem('projectId'); @@ -261,6 +361,77 @@ export default function CreateOrEditProjectBody({ /> )}
+ +
+ +
+ + +
+

+ *Repository name will be named after the project name itself. +

+
+ + {useExternalGit && isExternalGitIntegration && ( + <> +