From 78385cc2d9764133bb0fbb00efdfde2ead16984e Mon Sep 17 00:00:00 2001 From: Jason McCullough Date: Wed, 28 Jan 2026 20:42:34 +0000 Subject: [PATCH 1/2] feat: Data Access POC initiated with Get, Create and Update functions for BugSnag Projects --- go.mod | 2 +- go.sum | 2 - main.go | 21 +++ pkg/data_access/data_access_service.go | 40 ++++++ pkg/data_access/projects.go | 174 +++++++++++++++++++++++++ pkg/options/data_access/data_access.go | 13 ++ pkg/options/data_access/projects.go | 31 +++++ pkg/options/options.go | 4 + pkg/upload/react-native-sourcemaps.go | 2 +- 9 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 pkg/data_access/data_access_service.go create mode 100644 pkg/data_access/projects.go create mode 100644 pkg/options/data_access/data_access.go create mode 100644 pkg/options/data_access/projects.go diff --git a/go.mod b/go.mod index 01d18078..a151b99d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/stretchr/testify v1.8.4 golang.org/x/text v0.14.0 google.golang.org/protobuf v1.34.2 + howett.net/plist v1.0.1 ) require ( @@ -21,5 +22,4 @@ require ( golang.org/x/sys v0.19.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - howett.net/plist v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index fec81fbf..19c7c934 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= diff --git a/main.go b/main.go index 1fba6b3e..a27029c7 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/alecthomas/kong" "github.com/bugsnag/bugsnag-cli/pkg/build" + "github.com/bugsnag/bugsnag-cli/pkg/data_access" "github.com/bugsnag/bugsnag-cli/pkg/log" "github.com/bugsnag/bugsnag-cli/pkg/options" "github.com/bugsnag/bugsnag-cli/pkg/upload" @@ -40,8 +41,28 @@ func main() { logger.Info("Performing dry run - no data will be sent to BugSnag") } + dataAccessService := data_access.NewService() + switch kongCtx.Command() { + case "create project": + err := dataAccessService.Projects.Create(commands, logger) + if err != nil { + logger.Fatal(err.Error()) + } + + case "get project": + err := dataAccessService.Projects.Get(commands, logger) + if err != nil { + logger.Fatal(err.Error()) + } + + case "update project": + err := dataAccessService.Projects.Update(commands, logger) + if err != nil { + logger.Fatal(err.Error()) + } + case "upload all ": if commands.ApiKey == "" { diff --git a/pkg/data_access/data_access_service.go b/pkg/data_access/data_access_service.go new file mode 100644 index 00000000..b471e485 --- /dev/null +++ b/pkg/data_access/data_access_service.go @@ -0,0 +1,40 @@ +package data_access + +import ( + "net/http" + "os" + "time" + + "github.com/bugsnag/bugsnag-cli/pkg/log" + "github.com/bugsnag/bugsnag-cli/pkg/options" +) + +// Projects defines the project-related behaviour exposed by the data access layer. +type Projects interface { + Create(globalOptions options.CLI, logger *log.LoggerWrapper) error + Get(globalOptions options.CLI, logger *log.LoggerWrapper) error + Update(globalOptions options.CLI, logger *log.LoggerWrapper) error +} + +type Service struct { + httpClient *http.Client + bugsnagOrg BugsnagOrganization + + // Projects provides access to project-related operations, e.g. dataAccessService.Projects.Create(...). + Projects Projects +} + +func NewService() *Service { + s := &Service{ + httpClient: &http.Client{Timeout: 60 * time.Second}, + bugsnagOrg: BugsnagOrganization{ + ID: os.Getenv("BUGSNAG_ACCOUNT_ID"), + AuthToken: os.Getenv("BUGSNAG_CLI_PERSONAL_AUTH_TOKEN"), + }, + } + + // Wire a concrete ProjectClient (defined in projects.go) that delegates to this Service's shared state. + s.Projects = &ProjectClient{service: s} + + return s +} diff --git a/pkg/data_access/projects.go b/pkg/data_access/projects.go new file mode 100644 index 00000000..c028129d --- /dev/null +++ b/pkg/data_access/projects.go @@ -0,0 +1,174 @@ +package data_access + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/bugsnag/bugsnag-cli/pkg/log" + "github.com/bugsnag/bugsnag-cli/pkg/options" +) + +const baseURL = "http://localhost:3000" + +type BugsnagOrganization struct { + ID string + AuthToken string +} + +type BugsnagProject struct { + Name string `json:"name"` + Type string `json:"type"` + GlobalGrouping []string `json:"global_grouping,omitempty"` + LocationGrouping []string `json:"location_grouping,omitempty"` + DiscardedAppVersions []string `json:"discarded_app_versions,omitempty"` + DiscardedErrors []string `json:"discarded_errors,omitempty"` + URLWhitelist []string `json:"url_whitelist,omitempty"` + IgnoreOldBrowsers bool `json:"ignore_old_browsers,omitempty"` + IgnoredBrowserVersions map[string]any `json:"ignored_browser_versions,omitempty"` + ResolveOnDeploy bool `json:"resolve_on_deploy,omitempty"` + CollaboratorIDs []string `json:"collaborator_ids,omitempty"` +} + +// ProjectClient implements project-related operations using a shared Service. +type ProjectClient struct { + service *Service +} + +func (p *ProjectClient) Create(globalOptions options.CLI, logger *log.LoggerWrapper) error { + if err := p.service.checkEnvironmentVariables(); err != nil { + return err + } + + if globalOptions.Create.Project.Name == "" || globalOptions.Create.Project.Type == "" { + return fmt.Errorf("project name and type must be provided to create a new project") + } + + project := globalOptions.Create.Project + createProjectURL := fmt.Sprintf("%s/organizations/%s/projects", baseURL, p.service.bugsnagOrg.ID) + bugsnagProject := BugsnagProject{Name: project.Name, Type: project.Type, IgnoreOldBrowsers: project.IgnoreOldBrowsers} + + jsonBody, err := json.Marshal(bugsnagProject) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, createProjectURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + + responseBody, err := p.service.sendRequest(req) + if err != nil { + return err + } + logger.Info("Successfully created a new BugSnag project with the following details:\n" + responseBody) + + return nil +} + +func (p *ProjectClient) Get(globalOptions options.CLI, logger *log.LoggerWrapper) error { + if err := p.service.checkEnvironmentVariables(); err != nil { + return err + } + + if globalOptions.Get.Project.ID == "" { + return fmt.Errorf("project ID must be provided to get project details") + } + + projectID := globalOptions.Get.Project.ID + getProjectURL := fmt.Sprintf("%s/projects/%s", baseURL, projectID) + + req, err := http.NewRequest(http.MethodGet, getProjectURL, nil) + if err != nil { + return err + } + + responseBody, err := p.service.sendRequest(req) + if err != nil { + return err + } + logger.Info("Successfully retrieved details for a BugSnag project:\n" + responseBody) + + return nil +} + +func (p *ProjectClient) Update(globalOptions options.CLI, logger *log.LoggerWrapper) error { + if err := p.service.checkEnvironmentVariables(); err != nil { + return err + } + + if globalOptions.Update.Project.ID == "" { + return fmt.Errorf("project ID must be provided to update a project") + } + + if globalOptions.Update.Project.Name == "" || globalOptions.Update.Project.Type == "" { + return fmt.Errorf("project name and type must be provided to update a project") + } + + project := globalOptions.Update.Project + + bugsnagProject := BugsnagProject{ + Name: project.Name, + Type: project.Type, + GlobalGrouping: project.GlobalGrouping, + LocationGrouping: project.LocationGrouping, + DiscardedAppVersions: project.DiscardedAppVersions, + DiscardedErrors: project.DiscardedErrors, + URLWhitelist: project.URLWhitelist, + IgnoreOldBrowsers: project.IgnoreOldBrowsers, + IgnoredBrowserVersions: project.IgnoredBrowserVersions, + ResolveOnDeploy: project.ResolveOnDeploy, + CollaboratorIDs: project.CollaboratorIDs, + } + + jsonBody, err := json.Marshal(bugsnagProject) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/projects/%s", baseURL, project.ID), bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + + responseBody, err := p.service.sendRequest(req) + if err != nil { + return err + } + logger.Info("Successfully updated BugSnag project with the following details:\n" + responseBody) + + return nil +} + +func (s *Service) checkEnvironmentVariables() error { + if s.bugsnagOrg.ID == "" { + return fmt.Errorf("missing required environment variable BUGSNAG_ACCOUNT_ID") + } + + if s.bugsnagOrg.AuthToken == "" { + return fmt.Errorf("missing required environment variable BUGSNAG_CLI_PERSONAL_AUTH_TOKEN") + } + + return nil +} + +func (s *Service) sendRequest(req *http.Request) (string, error) { + req.Header.Set("X-Bugsnag-Api", "true") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("token %s", s.bugsnagOrg.AuthToken)) + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(bodyBytes), nil +} diff --git a/pkg/options/data_access/data_access.go b/pkg/options/data_access/data_access.go new file mode 100644 index 00000000..1561dae6 --- /dev/null +++ b/pkg/options/data_access/data_access.go @@ -0,0 +1,13 @@ +package data_access + +type Create struct { + Project Project `cmd:"project" help:"Create a project"` +} + +type Get struct { + Project Project `cmd:"project" help:"Get a project via it's ID'"` +} + +type Update struct { + Project Project `cmd:"project" help:"Create a project"` +} diff --git a/pkg/options/data_access/projects.go b/pkg/options/data_access/projects.go new file mode 100644 index 00000000..9c931161 --- /dev/null +++ b/pkg/options/data_access/projects.go @@ -0,0 +1,31 @@ +package data_access + +type Project struct { + ID string `help:"The ID of the project to retrieve"` + Name string `help:"The name of the project"` + Type string `help:"The project type (e.g. 'js', 'android', 'ios')"` + GlobalGrouping []string `help:"List of error classes to group globally by class (global_grouping)"` + LocationGrouping []string `help:"List of error classes to group by context (location_grouping)"` + DiscardedAppVersions []string `help:"App versions to discard events for (discarded_app_versions). Supports regex and semver ranges"` + DiscardedErrors []string `help:"Error classes to discard events for (discarded_errors)"` + URLWhitelist []string `help:"List of whitelisted script source domains (url_whitelist)"` + IgnoreOldBrowsers bool `help:"Whether to ignore old browsers for this project"` + IgnoredBrowserVersions map[string]any `help:"Ignored browser versions for JS projects (ignored_browser_versions)"` + ResolveOnDeploy bool `help:"Whether to mark all errors as fixed on deploy (resolve_on_deploy)"` + CollaboratorIDs []string `help:"List of collaborator IDs to set on the project (collaborator_ids)"` +} + +type ProjectActions struct { + Create createProject `cmd:"" help:"Create a project"` + Get getProject `cmd:"" help:"Get a project"` +} + +type createProject struct { + Name string `help:"The name of the project" required:""` + Type string `help:"The project type (e.g. 'js', 'android', 'ios')" required:""` + IgnoreOldBrowsers bool `help:"Whether to ignore old browsers for this project" default:"true"` +} + +type getProject struct { + ProjectID string `help:"The ID of the project to retrieve" required:""` +} diff --git a/pkg/options/options.go b/pkg/options/options.go index 81b300bb..d3ab3c3d 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -1,6 +1,7 @@ package options import ( + "github.com/bugsnag/bugsnag-cli/pkg/options/data_access" "github.com/bugsnag/bugsnag-cli/pkg/utils" ) @@ -147,6 +148,9 @@ type CLI struct { CreateAndroidBuildId CreateAndroidBuildId `cmd:"" help:"Generate a reproducible Build ID from .dex files"` CreateBuild CreateBuild `cmd:"" help:"Provide extra information whenever you build, release, or deploy your application"` Upload Upload `cmd:"" help:"Upload symbol/mapping files"` + Create data_access.Create `cmd:"" help:"Create stuff"` + Get data_access.Get `cmd:"" help:"Get stuff"` + Update data_access.Update `cmd:"" help:"Update stuff"` } type CreateAndroidBuildId struct { diff --git a/pkg/upload/react-native-sourcemaps.go b/pkg/upload/react-native-sourcemaps.go index 22c888dc..9475cf69 100644 --- a/pkg/upload/react-native-sourcemaps.go +++ b/pkg/upload/react-native-sourcemaps.go @@ -73,7 +73,7 @@ func ProcessReactNativeSourcemaps(globalOptions options.CLI, logger log.Logger) // Determine project root if reactNativeOpts.ProjectRoot == "" { uploadOpts["projectRoot"] = string(reactNativeOpts.Path) - logger.Debug(fmt.Sprintf("Project root not provided — using inferred path: %s", uploadOpts["projectRoot"])) + logger.Debug(fmt.Sprintf("ProjectCommands root not provided — using inferred path: %s", uploadOpts["projectRoot"])) } else { uploadOpts["projectRoot"] = reactNativeOpts.ProjectRoot logger.Debug(fmt.Sprintf("Using specified project root: %s", reactNativeOpts.ProjectRoot)) From 710a14a2c37ae678672a0e490dbf1973b07764e6 Mon Sep 17 00:00:00 2001 From: Jason McCullough Date: Wed, 28 Jan 2026 20:46:34 +0000 Subject: [PATCH 2/2] fix: find+replace error correction --- pkg/upload/react-native-sourcemaps.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/upload/react-native-sourcemaps.go b/pkg/upload/react-native-sourcemaps.go index 9475cf69..22c888dc 100644 --- a/pkg/upload/react-native-sourcemaps.go +++ b/pkg/upload/react-native-sourcemaps.go @@ -73,7 +73,7 @@ func ProcessReactNativeSourcemaps(globalOptions options.CLI, logger log.Logger) // Determine project root if reactNativeOpts.ProjectRoot == "" { uploadOpts["projectRoot"] = string(reactNativeOpts.Path) - logger.Debug(fmt.Sprintf("ProjectCommands root not provided — using inferred path: %s", uploadOpts["projectRoot"])) + logger.Debug(fmt.Sprintf("Project root not provided — using inferred path: %s", uploadOpts["projectRoot"])) } else { uploadOpts["projectRoot"] = reactNativeOpts.ProjectRoot logger.Debug(fmt.Sprintf("Using specified project root: %s", reactNativeOpts.ProjectRoot))