diff --git a/api/internal/features/github-connector/service/get_github_repositories.go b/api/internal/features/github-connector/service/get_github_repositories.go index 68a7111ce..491bb306d 100644 --- a/api/internal/features/github-connector/service/get_github_repositories.go +++ b/api/internal/features/github-connector/service/get_github_repositories.go @@ -15,58 +15,8 @@ import ( // If connectorID is provided, it uses that specific connector. Otherwise, it finds a connector with a valid installation_id. // If search is provided, it fetches all repositories and filters them by the search term before applying pagination. func (c *GithubConnectorService) GetGithubRepositoriesPaginated(userID string, page int, pageSize int, connectorID string, search string) ([]shared_types.GithubRepository, int, error) { - connectors, err := c.storage.GetAllConnectors(userID) + accessToken, err := c.getAccessTokenForUser(userID, connectorID) if err != nil { - c.logger.Log(logger.Error, err.Error(), "") - return nil, 0, err - } - - if len(connectors) == 0 { - c.logger.Log(logger.Error, "No connectors found for user", userID) - return []shared_types.GithubRepository{}, 0, nil - } - - var connectorToUse *shared_types.GithubConnector - - // If connectorID is provided, find that specific connector - if connectorID != "" { - for i := range connectors { - if connectors[i].ID.String() == connectorID { - connectorToUse = &connectors[i] - break - } - } - if connectorToUse == nil { - c.logger.Log(logger.Error, fmt.Sprintf("Connector with id %s not found for user", connectorID), userID) - return nil, 0, fmt.Errorf("connector not found") - } - } else { - // Find connector with valid installation_id (not empty) - for i := range connectors { - if connectors[i].InstallationID != "" && connectors[i].InstallationID != " " { - connectorToUse = &connectors[i] - break - } - } - // If no connector with installation_id found, return error - if connectorToUse == nil { - c.logger.Log(logger.Error, "No connector with valid installation_id found for user", userID) - return nil, 0, fmt.Errorf("no connector with valid installation found") - } - } - - // Validate installation_id is not empty - if connectorToUse.InstallationID == "" || connectorToUse.InstallationID == " " { - c.logger.Log(logger.Error, fmt.Sprintf("Connector %s has empty installation_id", connectorToUse.ID.String()), userID) - return nil, 0, fmt.Errorf("connector has no installation_id") - } - - installation_id := connectorToUse.InstallationID - jwt := GenerateJwt(connectorToUse) - - accessToken, err := c.getInstallationToken(jwt, installation_id) - if err != nil { - c.logger.Log(logger.Error, fmt.Sprintf("Failed to get installation token: %s", err.Error()), "") return nil, 0, err } diff --git a/api/internal/features/github-connector/service/installation_token.go b/api/internal/features/github-connector/service/installation_token.go index 272195af8..f41f68bda 100644 --- a/api/internal/features/github-connector/service/installation_token.go +++ b/api/internal/features/github-connector/service/installation_token.go @@ -9,6 +9,7 @@ import ( "time" "github.com/golang-jwt/jwt" + "github.com/raghavyuva/nixopus-api/internal/features/logger" shared_types "github.com/raghavyuva/nixopus-api/internal/types" ) @@ -72,3 +73,68 @@ func GenerateJwt(app_credentials *shared_types.GithubConnector) string { return tokenString } + +// getAccessTokenForUser retrieves an installation access token for the user. +// It uses the connectorID if provided, otherwise finds a connector with valid installation_id. +// This is a shared helper function used by multiple service methods. +func (c *GithubConnectorService) getAccessTokenForUser(userID string, connectorID string) (string, error) { + connectors, err := c.storage.GetAllConnectors(userID) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return "", err + } + + if len(connectors) == 0 { + c.logger.Log(logger.Error, "No connectors found for user", userID) + return "", fmt.Errorf("no connectors found") + } + + var connectorToUse *shared_types.GithubConnector + + // If connectorID is provided, find that specific connector + if connectorID != "" { + for i := range connectors { + if connectors[i].ID.String() == connectorID { + connectorToUse = &connectors[i] + break + } + } + if connectorToUse == nil { + c.logger.Log(logger.Error, fmt.Sprintf("Connector with id %s not found for user", connectorID), userID) + return "", fmt.Errorf("connector not found") + } + } else { + // Find connector with valid installation_id (not empty) + for i := range connectors { + if connectors[i].InstallationID != "" && connectors[i].InstallationID != " " { + connectorToUse = &connectors[i] + break + } + } + if connectorToUse == nil { + c.logger.Log(logger.Error, "No connector with valid installation_id found for user", userID) + return "", fmt.Errorf("no connector with valid installation found") + } + } + + // Validate installation_id is not empty + if connectorToUse.InstallationID == "" || connectorToUse.InstallationID == " " { + c.logger.Log(logger.Error, fmt.Sprintf("Connector %s has empty installation_id", connectorToUse.ID.String()), userID) + return "", fmt.Errorf("connector has no installation_id") + } + + installationID := connectorToUse.InstallationID + jwt := GenerateJwt(connectorToUse) + if jwt == "" { + c.logger.Log(logger.Error, "Failed to generate app JWT", "") + return "", fmt.Errorf("failed to generate app JWT") + } + + accessToken, err := c.getInstallationToken(jwt, installationID) + if err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to get installation token: %s", err.Error()), "") + return "", err + } + + return accessToken, nil +} diff --git a/api/internal/features/github-connector/service/issues.go b/api/internal/features/github-connector/service/issues.go new file mode 100644 index 000000000..9eec2e592 --- /dev/null +++ b/api/internal/features/github-connector/service/issues.go @@ -0,0 +1,269 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/raghavyuva/nixopus-api/internal/features/github-connector/types" + "github.com/raghavyuva/nixopus-api/internal/features/logger" +) + +// CreateIssue creates a new GitHub issue in the specified repository +func (c *GithubConnectorService) CreateIssue(userID string, req *types.CreateIssueRequest) (*types.IssueResponse, error) { + accessToken, err := c.getAccessTokenForUser(userID, req.ConnectorID) + if err != nil { + return nil, err + } + + repoFullName := fmt.Sprintf("%s/%s", req.RepositoryOwner, req.RepositoryName) + url := fmt.Sprintf("%s/repos/%s/issues", githubAPIBaseURL, repoFullName) + + requestBody := map[string]interface{}{ + "title": req.Title, + "body": req.Body, + } + if len(req.Labels) > 0 { + requestBody["labels"] = req.Labels + } + if len(req.Assignees) > 0 { + requestBody["assignees"] = req.Assignees + } + + jsonBody, err := json.Marshal(requestBody) + if err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to marshal request body: %s", err.Error()), "") + return nil, err + } + + client := &http.Client{} + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + + httpReq.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken)) + httpReq.Header.Set("Accept", "application/vnd.github.v3+json") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "nixopus") + + resp, err := client.Do(httpReq) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusCreated { + c.logger.Log(logger.Error, fmt.Sprintf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)), "") + return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)) + } + + var issue types.IssueResponse + if err := json.Unmarshal(bodyBytes, &issue); err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to unmarshal response: %s", err.Error()), "") + return nil, err + } + + return &issue, nil +} + +// UpdateIssue updates an existing GitHub issue +func (c *GithubConnectorService) UpdateIssue(userID string, req *types.UpdateIssueRequest) (*types.IssueResponse, error) { + accessToken, err := c.getAccessTokenForUser(userID, req.ConnectorID) + if err != nil { + return nil, err + } + + repoFullName := fmt.Sprintf("%s/%s", req.RepositoryOwner, req.RepositoryName) + url := fmt.Sprintf("%s/repos/%s/issues/%d", githubAPIBaseURL, repoFullName, req.IssueNumber) + + // Prepare request body with only provided fields + requestBody := make(map[string]interface{}) + if req.Title != nil { + requestBody["title"] = *req.Title + } + if req.Body != nil { + requestBody["body"] = *req.Body + } + if req.State != nil { + requestBody["state"] = *req.State + } + if len(req.Labels) > 0 { + requestBody["labels"] = req.Labels + } + if len(req.Assignees) > 0 { + requestBody["assignees"] = req.Assignees + } + + jsonBody, err := json.Marshal(requestBody) + if err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to marshal request body: %s", err.Error()), "") + return nil, err + } + + client := &http.Client{} + httpReq, err := http.NewRequest("PATCH", url, bytes.NewBuffer(jsonBody)) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + + httpReq.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken)) + httpReq.Header.Set("Accept", "application/vnd.github.v3+json") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "nixopus") + + resp, err := client.Do(httpReq) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + c.logger.Log(logger.Error, fmt.Sprintf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)), "") + return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)) + } + + var issue types.IssueResponse + if err := json.Unmarshal(bodyBytes, &issue); err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to unmarshal response: %s", err.Error()), "") + return nil, err + } + + return &issue, nil +} + +// CommentOnIssue adds a comment to an existing GitHub issue +func (c *GithubConnectorService) CommentOnIssue(userID string, req *types.CommentOnIssueRequest) (*types.IssueCommentResponse, error) { + accessToken, err := c.getAccessTokenForUser(userID, req.ConnectorID) + if err != nil { + return nil, err + } + + repoFullName := fmt.Sprintf("%s/%s", req.RepositoryOwner, req.RepositoryName) + url := fmt.Sprintf("%s/repos/%s/issues/%d/comments", githubAPIBaseURL, repoFullName, req.IssueNumber) + + requestBody := map[string]string{ + "body": req.Body, + } + + jsonBody, err := json.Marshal(requestBody) + if err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to marshal request body: %s", err.Error()), "") + return nil, err + } + + client := &http.Client{} + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + + httpReq.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken)) + httpReq.Header.Set("Accept", "application/vnd.github.v3+json") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "nixopus") + + resp, err := client.Do(httpReq) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusCreated { + c.logger.Log(logger.Error, fmt.Sprintf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)), "") + return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)) + } + + var comment types.IssueCommentResponse + if err := json.Unmarshal(bodyBytes, &comment); err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to unmarshal response: %s", err.Error()), "") + return nil, err + } + + return &comment, nil +} + +// ListIssues lists GitHub issues for the specified repository +func (c *GithubConnectorService) ListIssues(userID string, req *types.ListIssuesRequest) ([]types.IssueResponse, error) { + accessToken, err := c.getAccessTokenForUser(userID, req.ConnectorID) + if err != nil { + return nil, err + } + + repoFullName := fmt.Sprintf("%s/%s", req.RepositoryOwner, req.RepositoryName) + apiURL := fmt.Sprintf("%s/repos/%s/issues", githubAPIBaseURL, repoFullName) + + // Build query parameters + queryParams := url.Values{} + if req.State != "" { + queryParams.Set("state", req.State) + } + if len(req.Labels) > 0 { + queryParams.Set("labels", strings.Join(req.Labels, ",")) + } + if req.Assignee != "" { + queryParams.Set("assignee", req.Assignee) + } + if req.Creator != "" { + queryParams.Set("creator", req.Creator) + } + if req.Page > 0 { + queryParams.Set("page", fmt.Sprintf("%d", req.Page)) + } + if req.PerPage > 0 { + queryParams.Set("per_page", fmt.Sprintf("%d", req.PerPage)) + } + + if len(queryParams) > 0 { + apiURL += "?" + queryParams.Encode() + } + + client := &http.Client{} + httpReq, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + + httpReq.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken)) + httpReq.Header.Set("Accept", "application/vnd.github.v3+json") + httpReq.Header.Set("User-Agent", "nixopus") + + resp, err := client.Do(httpReq) + if err != nil { + c.logger.Log(logger.Error, err.Error(), "") + return nil, err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + c.logger.Log(logger.Error, fmt.Sprintf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)), "") + return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(bodyBytes)) + } + + var issues []types.IssueResponse + if err := json.Unmarshal(bodyBytes, &issues); err != nil { + c.logger.Log(logger.Error, fmt.Sprintf("Failed to unmarshal response: %s", err.Error()), "") + return nil, err + } + + return issues, nil +} diff --git a/api/internal/features/github-connector/types/init.go b/api/internal/features/github-connector/types/init.go index 8faa46da8..4b5861010 100644 --- a/api/internal/features/github-connector/types/init.go +++ b/api/internal/features/github-connector/types/init.go @@ -55,16 +55,126 @@ type ListBranchesResponse struct { Data []shared_types.GithubRepositoryBranch `json:"data"` } +// CreateIssueRequest represents the request to create a GitHub issue +type CreateIssueRequest struct { + RepositoryOwner string `json:"repository_owner"` + RepositoryName string `json:"repository_name"` + Title string `json:"title"` + Body string `json:"body"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` +} + +// UpdateIssueRequest represents the request to update a GitHub issue +type UpdateIssueRequest struct { + RepositoryOwner string `json:"repository_owner"` + RepositoryName string `json:"repository_name"` + IssueNumber int `json:"issue_number"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty"` // "open" or "closed" + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` +} + +// CommentOnIssueRequest represents the request to comment on a GitHub issue +type CommentOnIssueRequest struct { + RepositoryOwner string `json:"repository_owner"` + RepositoryName string `json:"repository_name"` + IssueNumber int `json:"issue_number"` + Body string `json:"body"` + ConnectorID string `json:"connector_id,omitempty"` +} + +// ListIssuesRequest represents the request to list GitHub issues +type ListIssuesRequest struct { + RepositoryOwner string `json:"repository_owner"` + RepositoryName string `json:"repository_name"` + State string `json:"state,omitempty"` // "open", "closed", or "all" + Labels []string `json:"labels,omitempty"` // Filter by labels + Assignee string `json:"assignee,omitempty"` // Filter by assignee + Creator string `json:"creator,omitempty"` // Filter by creator + Page int `json:"page,omitempty"` // Page number (default: 1) + PerPage int `json:"per_page,omitempty"` // Items per page (default: 30, max: 100) + ConnectorID string `json:"connector_id,omitempty"` +} + +// IssueResponse represents a GitHub issue response +type IssueResponse struct { + ID int `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + User struct { + Login string `json:"login"` + } `json:"user"` +} + +// IssueCommentResponse represents a GitHub issue comment response +type IssueCommentResponse struct { + ID int `json:"id"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + User struct { + Login string `json:"login"` + } `json:"user"` +} + +// CreateIssueResponse is the typed response for creating an issue +type CreateIssueResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data IssueResponse `json:"data"` +} + +// UpdateIssueResponse is the typed response for updating an issue +type UpdateIssueResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data IssueResponse `json:"data"` +} + +// CommentOnIssueResponse is the typed response for commenting on an issue +type CommentOnIssueResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data IssueCommentResponse `json:"data"` +} + +// ListIssuesResponseData contains the issues data with pagination +type ListIssuesResponseData struct { + Issues []IssueResponse `json:"issues"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalCount int `json:"total_count,omitempty"` +} + +// ListIssuesResponse is the typed response for listing issues +type ListIssuesResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data ListIssuesResponseData `json:"data"` +} + var ( - ErrMissingSlug = errors.New("slug is required") - ErrMissingPem = errors.New("pem is required") - ErrMissingClientID = errors.New("client_id is required") - ErrMissingClientSecret = errors.New("client_secret is required") - ErrMissingWebhookSecret = errors.New("webhook_secret is required") - ErrMissingInstallationID = errors.New("installation_id is required") - ErrMissingID = errors.New("id is required") - ErrInvalidRequestType = errors.New("invalid request type") - ErrConnectorDoesNotExist = errors.New("connector does not exist") - ErrNoConnectors = errors.New("no connectors found") - ErrPermissionDenied = errors.New("permission denied") + ErrMissingSlug = errors.New("slug is required") + ErrMissingPem = errors.New("pem is required") + ErrMissingClientID = errors.New("client_id is required") + ErrMissingClientSecret = errors.New("client_secret is required") + ErrMissingWebhookSecret = errors.New("webhook_secret is required") + ErrMissingInstallationID = errors.New("installation_id is required") + ErrMissingID = errors.New("id is required") + ErrInvalidRequestType = errors.New("invalid request type") + ErrConnectorDoesNotExist = errors.New("connector does not exist") + ErrNoConnectors = errors.New("no connectors found") + ErrPermissionDenied = errors.New("permission denied") + ErrMissingRepositoryOwner = errors.New("repository_owner is required") + ErrMissingRepositoryName = errors.New("repository_name is required") + ErrMissingTitle = errors.New("title is required") + ErrMissingBody = errors.New("body is required") + ErrMissingIssueNumber = errors.New("issue_number is required") ) diff --git a/api/internal/features/github-connector/validation/validator.go b/api/internal/features/github-connector/validation/validator.go index 8154e3799..28ffb6700 100644 --- a/api/internal/features/github-connector/validation/validator.go +++ b/api/internal/features/github-connector/validation/validator.go @@ -30,6 +30,9 @@ func NewValidator(storage GithubConnectorRepository) *Validator { // - types.CreateGithubConnectorRequest // - types.UpdateGithubConnectorRequest // - types.DeleteGithubConnectorRequest +// - types.CreateIssueRequest +// - types.UpdateIssueRequest +// - types.CommentOnIssueRequest // // If the request object is not of one of the above types, it returns // types.ErrInvalidRequestType. @@ -41,6 +44,12 @@ func (v *Validator) ValidateRequest(req any) error { return v.validateUpdateGithubConnectorRequest(*r) case *types.DeleteGithubConnectorRequest: return v.validateDeleteGithubConnectorRequest(*r) + case *types.CreateIssueRequest: + return v.validateCreateIssueRequest(*r) + case *types.UpdateIssueRequest: + return v.validateUpdateIssueRequest(*r) + case *types.CommentOnIssueRequest: + return v.validateCommentOnIssueRequest(*r) default: return types.ErrInvalidRequestType } @@ -92,3 +101,45 @@ func (v *Validator) validateDeleteGithubConnectorRequest(req types.DeleteGithubC return nil } + +func (v *Validator) validateCreateIssueRequest(req types.CreateIssueRequest) error { + if req.RepositoryOwner == "" { + return types.ErrMissingRepositoryOwner + } + if req.RepositoryName == "" { + return types.ErrMissingRepositoryName + } + if req.Title == "" { + return types.ErrMissingTitle + } + return nil +} + +func (v *Validator) validateUpdateIssueRequest(req types.UpdateIssueRequest) error { + if req.RepositoryOwner == "" { + return types.ErrMissingRepositoryOwner + } + if req.RepositoryName == "" { + return types.ErrMissingRepositoryName + } + if req.IssueNumber == 0 { + return types.ErrMissingIssueNumber + } + return nil +} + +func (v *Validator) validateCommentOnIssueRequest(req types.CommentOnIssueRequest) error { + if req.RepositoryOwner == "" { + return types.ErrMissingRepositoryOwner + } + if req.RepositoryName == "" { + return types.ErrMissingRepositoryName + } + if req.IssueNumber == 0 { + return types.ErrMissingIssueNumber + } + if req.Body == "" { + return types.ErrMissingBody + } + return nil +}