diff --git a/cmd/import.go b/cmd/import.go index a3da5b4..79e1121 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -50,7 +50,11 @@ var ImportCommand = &cli.Command{ func importAction(cliCtx *cli.Context) error { providerName := cliCtx.String("provider") - provider, err := pager.NewPager(providerName, cliCtx.String("provider-api-key"), cliCtx.String("provider-app-id")) + provider, err := pager.NewPager( + providerName, + cliCtx.String("provider-api-key"), + cliCtx.String("provider-app-id"), + ) if err != nil { return fmt.Errorf("initializing pager provider: %w", err) } @@ -145,16 +149,38 @@ func importEscalationPolicies(ctx context.Context, provider pager.Pager, fh *pag } func importTeams(ctx context.Context, provider pager.Pager, fh *pager.FireHydrant) error { + // Some providers made their users adopt an alternate concept of teams. + // + // For example, PagerDuty has "Teams" and "Services". In vacuum, they intuitively refer to + // "people" and "computers", respectively. However, their implementation for on call schedule + // is tied to "Services". As such, many users of PagerDuty never really defined "Teams" in + // their instance and use "Services" in practice for grouping on call "Teams". + // + // Now, all of that is imported as "Teams" in FireHydrant. As such, we prompt user to select + // their logical representation of "Teams" when a provider has multiple options. + // There may be a case where users may want to import both to FireHydrant. It is not currently + // supported, but can be a reasonable future enhancement. + if choices := provider.TeamInterfaces(); len(choices) > 1 { + _, ti, err := console.Selectf(choices, func(s string) string { + return fmt.Sprintf("%s %s", provider.Kind(), s) + }, "Let's fill out your teams in FireHydrant. Which team interface would you like to use?") + if err != nil { + return fmt.Errorf("selecting team interface: %w", err) + } + if err := provider.UseTeamInterface(ti); err != nil { + return fmt.Errorf("setting team interface: %w", err) + } + } + // Get all of the teams registered from Pager Provider (e.g. PagerDuty) var err error - var providerTeams []*pager.Team console.Spin(func() { - providerTeams, err = provider.ListTeams(ctx) + err = provider.LoadTeams(ctx) }, "Fetching all teams from provider...") if err != nil { - return fmt.Errorf("unable to fetch teamsfrom provider: %w", err) + return fmt.Errorf("unable to fetch teams from provider: %w", err) } - console.Successf("Found %d teams from provider.\n", len(providerTeams)) + console.Successf("Loaded all teams from provider.\n") // List out all of the teams from FireHydrant. var fhTeams []*pager.Team @@ -166,140 +192,104 @@ func importTeams(ctx context.Context, provider pager.Pager, fh *pager.FireHydran } console.Successf("Found %d teams on FireHydrant.\n", len(fhTeams)) - // Now, for every team we found in Pager provider, we prompt console for one of three choices: - // 1. Create a new team in FireHydrant - // 2. Match with an existing team in FireHydrant - // 3. Skip / ignore the team entirely - options := append([]*pager.Team{ - &pager.Team{Slug: "[<] Skip"}, - &pager.Team{Slug: "[+] Create"}, - }, fhTeams...) - for _, extTeam := range providerTeams { - i, t, err := console.Selectf(options, func(u *pager.Team) string { - return u.String() - }, "For the team '%s' from provider:", extTeam.String()) - if err != nil { - return fmt.Errorf("selecting match for '%s': %w", extTeam.String(), err) - } - switch i { - case 0: - console.Warnf("[< SKIPPED] '%s' will not be imported to FireHydrant.\n", extTeam.String()) - continue - case 1: - console.Successf("[+ CREATE] '%s' will be created in FireHydrant.\n", extTeam.String()) - if err := store.UseQueries(ctx).InsertExtTeam(ctx, store.InsertExtTeamParams{ - ID: extTeam.ID, - Name: extTeam.Name, - Slug: extTeam.Slug, - FhTeamID: sql.NullString{Valid: false}, - }); err != nil { - return fmt.Errorf("unable to insert team '%s' into database: %w", extTeam.String(), err) - } - continue - default: - if err := store.UseQueries(ctx).InsertExtTeam(ctx, store.InsertExtTeamParams{ - ID: extTeam.ID, - Name: extTeam.Name, - Slug: extTeam.Slug, - FhTeamID: sql.NullString{Valid: true, String: t.ID}, - }); err != nil { - return fmt.Errorf("unable to insert team '%s' into database: %w", extTeam.String(), err) - } else { - console.Infof("[= MATCHED]\n '%s'\n => '%s'.\n", extTeam.String(), t.String()) - } - } + if err := provider.LoadTeamMembers(ctx); err != nil { + return fmt.Errorf("unable to populate team members: %w", err) } - allTeams, err := store.UseQueries(ctx).ListTeams(ctx) + // First, we prompt users which teams to import to FireHydrant from the external provider. + // We will mark the selected teams to import, then ask for user to match existing teams in FireHydrant (or create new). + teams, err := provider.Teams(ctx) if err != nil { - return fmt.Errorf("unable to list all teams: %w", err) + return fmt.Errorf("unable to list teams: %w", err) } - for _, extTeam := range allTeams { - t := &pager.Team{ - Resource: pager.Resource{ - ID: extTeam.ID, - Name: extTeam.Name, - }, - Slug: extTeam.Slug, - } - if err := provider.PopulateTeamMembers(ctx, t); err != nil { - return fmt.Errorf("unable to populate team members for '%s': %w", extTeam.Name, err) + console.Warnf("Please select which teams to migrate to FireHydrant.\n") + _, toImport, err := console.MultiSelectf(teams, func(t store.ExtTeam) string { + return fmt.Sprintf("%s %s", t.ID, t.Name) + }, "Which teams should be migrated to FireHydrant?") + if err != nil { + return fmt.Errorf("selecting teams: %w", err) + } + for _, t := range toImport { + if err := store.UseQueries(ctx).MarkExtTeamToImport(ctx, t.ID); err != nil { + return fmt.Errorf("unable to mark team '%s' for import: %w", t.Name, err) } + } - for _, member := range t.Members { - if err := store.UseQueries(ctx).InsertExtMembership(ctx, store.InsertExtMembershipParams{ - TeamID: extTeam.ID, - UserID: member.ID, - }); err != nil { - return fmt.Errorf("unable to insert team member '%s' into database: %w", member.String(), err) - } + // Now, we prompt users to match the teams that we are importing to FireHydrant. + options := []*pager.Team{{Resource: pager.Resource{ID: "[+] CREATE NEW"}}} + options = append(options, fhTeams...) + for _, t := range toImport { + selected, fhTeam, err := console.Selectf(options, func(t *pager.Team) string { + return fmt.Sprintf("%s %s", t.ID, t.Name) + }, fmt.Sprintf("Which FireHydrant team should '%s' be imported to?", t.Name)) + if err != nil { + return fmt.Errorf("selecting FireHydrant team for '%s': %w", t.Name, err) + } + if selected == 0 { + console.Infof("[+] Team '%s' will be created as new team in FireHydrant.\n", t.Name) + continue + } + if err := store.UseQueries(ctx).LinkExtTeam(ctx, store.LinkExtTeamParams{ + ID: t.ID, + FhTeamID: sql.NullString{String: fhTeam.ID, Valid: true}, + }); err != nil { + return fmt.Errorf("linking team '%s' to FireHydrant: %w", t.Name, err) } } - return nil } func importUsers(ctx context.Context, provider pager.Pager, fh *pager.FireHydrant) error { // Get all of the users registered from Pager Provider (e.g. PagerDuty) var err error - var providerUsers []*pager.User console.Spin(func() { - providerUsers, err = provider.ListUsers(ctx) + err = provider.LoadUsers(ctx) }, "Fetching all users from provider...") if err != nil { return fmt.Errorf("unable to fetch users from provider: %w", err) } - console.Successf("Found %d users from provider.\n", len(providerUsers)) - for _, user := range providerUsers { - if err := store.UseQueries(ctx).InsertExtUser(ctx, store.InsertExtUserParams{ - ID: user.ID, - Name: user.Name, - Email: user.Email, - FhUserID: sql.NullString{Valid: false}, - }); err != nil { - return fmt.Errorf("unable to insert user '%s' into database: %w", user.Email, err) - } - } + console.Successf("Loaded all users from %s.\n", provider.Kind()) // Find out which users do not already have a FireHydrant account - var unmatchedUsers []*pager.User console.Spin(func() { - unmatchedUsers, err = fh.MatchUsers(ctx, providerUsers) - if err != nil { - return - } - }, "Matching users with existing FireHydrant users...") + err = fh.MatchUsers(ctx) + }, "Matching users with existing FireHydrant users by email...") if err != nil { return fmt.Errorf("unable to match users to FireHydrant: %w", err) } - // Prompt console to match users manually if necessary. - if len(unmatchedUsers) > 0 { - console.Warnf("Found %d users which require manual mapping to FireHydrant.\n", len(unmatchedUsers)) - options, err := fh.ListUsers(ctx) + // Manually link users which do not have matching email addresses + unmatched, err := store.UseQueries(ctx).ListUnmatchedExtUsers(ctx) + if err != nil { + return fmt.Errorf("unable to list unmatched users: %w", err) + } + if len(unmatched) > 0 { + console.Warnf("Please match the following users to their FireHydrant account.\n") + fhUsers, err := fh.ListUsers(ctx) if err != nil { - return fmt.Errorf("unable to list users from FireHydrant: %w", err) + return fmt.Errorf("unable to list FireHydrant users: %w", err) } - console.Warnf("Please select from %d FireHydrant users to match.\n", len(options)) + options := []*pager.User{{Resource: pager.Resource{Name: "[<] SKIP"}}} + options = append(options, fhUsers...) - // Prepend options with a choice to skip - options = append([]*pager.User{&pager.User{Email: "[<] Skip"}}, options...) - for _, extUser := range unmatchedUsers { - i, fhUser, err := console.Selectf(options, func(u *pager.User) string { - return u.String() - }, "Select a FireHydrant user to match with '%s'", extUser.String()) + for _, u := range unmatched { + selected, fhUser, err := console.Selectf(options, func(u *pager.User) string { + return fmt.Sprintf("%s %s", u.Name, u.Email) + }, fmt.Sprintf("Which FireHydrant user should '%s' be imported to?", u.Name)) if err != nil { - return fmt.Errorf("selecting match for '%s': %w", extUser.String(), err) + return fmt.Errorf("selecting FireHydrant user for '%s': %w", u.Name, err) } - if i == 0 { - console.Warnf("[< SKIPPED] '%s' will not be imported to FireHydrant.\n", extUser.String()) + if selected == 0 { + console.Infof("[<] User '%s' will not be imported to FireHydrant.\n", u.Name) continue } - if err := fh.PairUsers(ctx, fhUser.ID, extUser.ID); err != nil { - return fmt.Errorf("pairing '%s' with '%s': %w", extUser.String(), fhUser.String(), err) - } else { - console.Successf("[= MATCHED]\n '%s'\n => '%s'.\n", extUser.String(), fhUser.String()) + if err := store.UseQueries(ctx).LinkExtUser(ctx, store.LinkExtUserParams{ + ID: u.ID, + FhUserID: sql.NullString{String: fhUser.ID, Valid: true}, + }); err != nil { + return fmt.Errorf("linking user '%s': %w", u.Name, err) } + console.Successf("[=] User '%s' linked to FireHydrant user '%s'.\n", u.Email, fhUser.Email) } } return nil diff --git a/console/select.go b/console/select.go index 1e570a7..05a8641 100644 --- a/console/select.go +++ b/console/select.go @@ -48,7 +48,7 @@ func MultiSelectf[T any](options []T, toString func(T) string, title string, arg values = slices.Clip(values) if len(values) == 0 { - Warnf("You have not selected any options.") + Warnf("You have not selected any options.\n") continue } Warnf("You have selected: \n") diff --git a/pager/firehydrant.go b/pager/firehydrant.go index 725c6b0..410f9ca 100644 --- a/pager/firehydrant.go +++ b/pager/firehydrant.go @@ -138,26 +138,29 @@ func (f *FireHydrant) toUser(user firehydrant.User) *User { // MatchUsers attempts to pair users in the parameter with its FireHydrant User counterpart. // Returns: a list of users which were not successfully matched. -func (f *FireHydrant) MatchUsers(ctx context.Context, users []*User) ([]*User, error) { +func (f *FireHydrant) MatchUsers(ctx context.Context) error { + q := store.UseQueries(ctx) + // Calling ListUsers just to make sure DB store exists. _, err := f.ListUsers(ctx) if err != nil { - return nil, fmt.Errorf("fetching FireHydrant users: %w", err) + return fmt.Errorf("fetching FireHydrant users: %w", err) + } + + users, err := q.ListUsersJoinByEmail(ctx) + if err != nil { + return fmt.Errorf("listing external users: %w", err) } - unmatchedUsers := []*User{} for _, user := range users { - fhUser, err := store.UseQueries(ctx).GetFhUserByEmail(ctx, user.Email) - if err == nil { - if err := f.PairUsers(ctx, fhUser.ID, user.ID); err != nil { - return nil, fmt.Errorf("pairing users: %w", err) + if user.FhUser.ID != "" { + if err := f.PairUsers(ctx, user.FhUser.ID, user.ExtUser.ID); err != nil { + return fmt.Errorf("pairing users: %w", err) } - } else { - unmatchedUsers = append(unmatchedUsers, user) } } - return unmatchedUsers, nil + return nil } func (f *FireHydrant) PairUsers(ctx context.Context, fhUserID string, extUserID string) error { diff --git a/pager/opsgenie.go b/pager/opsgenie.go index ce4ea64..b2bd067 100644 --- a/pager/opsgenie.go +++ b/pager/opsgenie.go @@ -24,20 +24,28 @@ type Opsgenie struct { } func NewOpsgenie(apiKey string) *Opsgenie { + conf := &client.Config{ApiKey: apiKey} + return NewOpsgenieWithConfig(conf) +} - // Create a new userClient - var userClient, _ = user.NewClient(&client.Config{ - ApiKey: apiKey, - }) - - var teamClient, _ = team.NewClient(&client.Config{ - ApiKey: apiKey, - }) - - var scheduleClient, _ = schedule.NewClient(&client.Config{ - ApiKey: apiKey, - }) +func NewOpsgenieWithURL(apiKey, url string) *Opsgenie { + conf := &client.Config{ApiKey: apiKey, OpsGenieAPIURL: client.ApiUrl(url)} + return NewOpsgenieWithConfig(conf) +} +func NewOpsgenieWithConfig(conf *client.Config) *Opsgenie { + userClient, err := user.NewClient(conf) + if err != nil { + panic(fmt.Sprintf("creating opsgenie user client: %v", err)) + } + teamClient, err := team.NewClient(conf) + if err != nil { + panic(fmt.Sprintf("creating opsgenie team client: %v", err)) + } + scheduleClient, err := schedule.NewClient(conf) + if err != nil { + panic(fmt.Sprintf("creating opsgenie schedule client: %v", err)) + } return &Opsgenie{ userClient: userClient, teamClient: teamClient, @@ -45,33 +53,96 @@ func NewOpsgenie(apiKey string) *Opsgenie { } } -func NewOpsgenieWithURL(apiKey, url string) *Opsgenie { +func (p *Opsgenie) Kind() string { + return "Opsgenie" +} - // Create a new userClient - var userClient, _ = user.NewClient(&client.Config{ - ApiKey: apiKey, - OpsGenieAPIURL: client.ApiUrl(url), - }) +func (o *Opsgenie) TeamInterfaces() []string { + return []string{"team"} +} - var teamClient, _ = team.NewClient(&client.Config{ - ApiKey: apiKey, - OpsGenieAPIURL: client.ApiUrl(url), - }) +func (o *Opsgenie) UseTeamInterface(string) error { + return nil +} - var scheduleClient, _ = schedule.NewClient(&client.Config{ - ApiKey: apiKey, - OpsGenieAPIURL: client.ApiUrl(url), - }) +func (o *Opsgenie) Teams(ctx context.Context) ([]store.ExtTeam, error) { + return store.UseQueries(ctx).ListExtTeams(ctx) +} - return &Opsgenie{ - userClient: userClient, - teamClient: teamClient, - scheduleClient: scheduleClient, +func (o *Opsgenie) LoadUsers(ctx context.Context) error { + opts := user.ListRequest{} + + for { + resp, err := o.userClient.List(ctx, &opts) + if err != nil { + return fmt.Errorf("listing users: %w", err) + } + + for _, user := range resp.Users { + if err := store.UseQueries(ctx).InsertExtUser(ctx, store.InsertExtUserParams{ + ID: user.Id, + Name: user.FullName, + Email: user.Username, + }); err != nil { + return fmt.Errorf("saving user to db: %w", err) + } + } + + // Results are paginated, so break if we're on the last page. + if resp.Paging.Next == "" { + break + } + opts.Offset += len(resp.Users) } + return nil } -func (p *Opsgenie) Kind() string { - return "opsgenie" +func (o *Opsgenie) LoadTeams(ctx context.Context) error { + opts := &team.ListTeamRequest{} + + resp, err := o.teamClient.List(ctx, opts) + if err != nil { + return fmt.Errorf("listing teams: %w", err) + } + + for _, t := range resp.Teams { + if err := store.UseQueries(ctx).InsertExtTeam(ctx, store.InsertExtTeamParams{ + ID: t.Id, + Name: t.Name, + // Opsgenie does not expose a slug, so generate one. + Slug: slug.Make(t.Name), + }); err != nil { + return fmt.Errorf("saving team to db: %w", err) + } + } + + return nil +} + +func (o *Opsgenie) LoadTeamMembers(ctx context.Context) error { + teams, err := store.UseQueries(ctx).ListTeams(ctx) + if err != nil { + return fmt.Errorf("listing teams: %w", err) + } + for _, t := range teams { + resp, err := o.teamClient.Get(ctx, &team.GetTeamRequest{ + IdentifierType: team.Id, + IdentifierValue: t.ID, + }) + if err != nil { + return fmt.Errorf("getting team members: %w", err) + } + + for _, m := range resp.Members { + if err := store.UseQueries(ctx).InsertExtMembership(ctx, store.InsertExtMembershipParams{ + TeamID: t.ID, + UserID: m.User.ID, + }); err != nil { + return fmt.Errorf("saving team member to db: %w", err) + } + } + } + return nil } func (o *Opsgenie) LoadSchedules(ctx context.Context) error { @@ -87,7 +158,7 @@ func (o *Opsgenie) LoadSchedules(ctx context.Context) error { for _, schedule := range resp.Schedule { // To decide: check enabled field and don't create if false? if err := o.saveScheduleToDB(ctx, schedule); err != nil { - return fmt.Errorf("error saving schedule to db: %w", err) + return fmt.Errorf("saving schedule to db: %w", err) } } @@ -109,7 +180,7 @@ func (o *Opsgenie) saveScheduleToDB(ctx context.Context, s schedule.Schedule) er for _, rotation := range resp.Schedule.Rotations { if err := o.saveRotationToDB(ctx, s, rotation); err != nil { - return fmt.Errorf("error saving schedule to db: %w", err) + return fmt.Errorf("saving schedule to db: %w", err) } } return nil @@ -151,7 +222,7 @@ func (o *Opsgenie) saveRotationToDB(ctx context.Context, s schedule.Schedule, r q := store.UseQueries(ctx) if err := q.InsertExtSchedule(ctx, ogsParams); err != nil { - return fmt.Errorf("error saving schedule: %w", err) + return fmt.Errorf("saving schedule: %w", err) } // ExtScheduleTeam @@ -163,7 +234,7 @@ func (o *Opsgenie) saveRotationToDB(ctx context.Context, s schedule.Schedule, r if strings.Contains(err.Error(), "FOREIGN KEY constraint") { console.Warnf("Team %s not found for schedule %s, skipping...\n", s.OwnerTeam.Id, ogsParams.ID) } else { - return fmt.Errorf("error saving schedule team: %w", err) + return fmt.Errorf("saving schedule team: %w", err) } } } else { @@ -181,7 +252,7 @@ func (o *Opsgenie) saveRotationToDB(ctx context.Context, s schedule.Schedule, r } else if strings.Contains(err.Error(), "UNIQUE constraint") { console.Warnf("User %s already exists for schedule %s, skipping duplicate...\n", p.Id, ogsParams.ID) } else { - return fmt.Errorf("error saving schedule user: %w", err) + return fmt.Errorf("saving schedule user: %w", err) } } } @@ -213,7 +284,7 @@ func (o *Opsgenie) saveRotationToDB(ctx context.Context, s schedule.Schedule, r EndTime: endTime.Format(time.TimeOnly), } if err := q.InsertExtScheduleRestriction(ctx, ogsRestrictionsParams); err != nil { - return fmt.Errorf("error saving time of day restriction: %w", err) + return fmt.Errorf("saving time of day restriction: %w", err) } } case og.TimeOfDay: @@ -239,7 +310,7 @@ func (o *Opsgenie) saveRotationToDB(ctx context.Context, s schedule.Schedule, r EndTime: endTime.Format(time.TimeOnly), } if err := q.InsertExtScheduleRestriction(ctx, ogsRestrictionsParams); err != nil { - return fmt.Errorf("error saving time of day restriction: %w", err) + return fmt.Errorf("saving time of day restriction: %w", err) } } default: @@ -255,84 +326,3 @@ func (o *Opsgenie) LoadEscalationPolicies(ctx context.Context) error { console.Warnf("opsgenie.LoadEscalationPolicies is not currently supported.") return nil } - -func (p *Opsgenie) PopulateTeamMembers(ctx context.Context, t *Team) error { - members := []*User{} - - resp, err := p.teamClient.Get(ctx, &team.GetTeamRequest{ - IdentifierType: team.Name, - IdentifierValue: t.Name, - }) - - if err != nil { - return err - } - - for _, member := range resp.Members { - members = append(members, &User{Resource: Resource{ID: member.User.ID}}) - } - - t.Members = members - - return nil -} - -func (p *Opsgenie) ListTeams(ctx context.Context) ([]*Team, error) { - teams := []*Team{} - opts := team.ListTeamRequest{} - - resp, err := p.teamClient.List(ctx, &opts) - if err != nil { - return nil, err - } - - for _, team := range resp.Teams { - teams = append(teams, p.toTeam(team)) - } - - return teams, nil -} - -func (p *Opsgenie) toTeam(team team.ListedTeams) *Team { - return &Team{ - // Opsgenie does not expose a slug, so generate one. - Slug: slug.Make(team.Name), - Resource: Resource{ - ID: team.Id, - Name: team.Name, - }, - } -} - -func (p *Opsgenie) ListUsers(ctx context.Context) ([]*User, error) { - users := []*User{} - opts := user.ListRequest{} - - for { - resp, err := p.userClient.List(ctx, &opts) - if err != nil { - return nil, err - } - - for _, user := range resp.Users { - users = append(users, p.toUser(user)) - } - - // Results are paginated, so break if we're on the last page. - if resp.Paging.Next == "" { - break - } - opts.Offset += len(resp.Users) - } - return users, nil -} - -func (p *Opsgenie) toUser(user user.User) *User { - return &User{ - Email: user.Username, - Resource: Resource{ - ID: user.Id, - Name: user.FullName, - }, - } -} diff --git a/pager/opsgenie_test.go b/pager/opsgenie_test.go index 86b8e8f..bcc5fd8 100644 --- a/pager/opsgenie_test.go +++ b/pager/opsgenie_test.go @@ -26,14 +26,17 @@ func TestOpsgenie(t *testing.T) { return ctx, og } - t.Run("ListUsers", func(t *testing.T) { + t.Run("LoadUsers", func(t *testing.T) { ctx, og := setup(t) - u, err := og.ListUsers(ctx) + if err := og.LoadUsers(ctx); err != nil { + t.Fatalf("error loading users: %s", err) + } + + u, err := store.UseQueries(ctx).ListExtUsers(ctx) if err != nil { t.Fatalf("error loading users: %s", err) } - t.Logf("found %d users", len(u)) assertJSON(t, u) }) diff --git a/pager/pager.go b/pager/pager.go index 6cf6ae1..6bb4450 100644 --- a/pager/pager.go +++ b/pager/pager.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + "github.com/firehydrant/signals-migrator/store" ) var ( @@ -13,13 +15,17 @@ var ( ) type Pager interface { - ListUsers(ctx context.Context) ([]*User, error) - ListTeams(ctx context.Context) ([]*Team, error) + Kind() string + TeamInterfaces() []string + UseTeamInterface(interfaceName string) error + LoadUsers(ctx context.Context) error + LoadTeams(ctx context.Context) error + LoadTeamMembers(ctx context.Context) error LoadSchedules(ctx context.Context) error LoadEscalationPolicies(ctx context.Context) error - PopulateTeamMembers(ctx context.Context, team *Team) error + Teams(context.Context) ([]store.ExtTeam, error) } func NewPager(kind string, apiKey string, appId string) (Pager, error) { diff --git a/pager/pager_test.go b/pager/pager_test.go index 4b52ec4..f62beb0 100644 --- a/pager/pager_test.go +++ b/pager/pager_test.go @@ -17,7 +17,11 @@ import ( func withTestDB(t *testing.T) context.Context { t.Helper() - ctx := store.WithContext(context.Background()) + + f := filepath.Join(t.TempDir(), slug.Make(t.Name())+".db") + _ = os.Remove(f) + + ctx := store.WithContextAndDSN(context.Background(), "file:"+f) t.Cleanup(func() { if err := store.FromContext(ctx).Close(); err != nil { t.Fatalf("error closing db: %s", err) @@ -35,7 +39,11 @@ func pagerProviderHttpServer(t *testing.T) *httptest.Server { for b := filepath.Dir(baseTestDir); b != "."; b = filepath.Dir(b) { baseTestDir = b } - filename, err := url.JoinPath("testdata", baseTestDir, "apiserver", slug.Make(r.URL.Path)+".json") + urlPath := r.URL.Path + if r.URL.RawQuery != "" { + urlPath += "?" + r.URL.RawQuery + } + filename, err := url.JoinPath("testdata", baseTestDir, "apiserver", slug.Make(urlPath)+".json") if err != nil { t.Fatalf("error joining path for expected response: %s", err) } @@ -52,6 +60,7 @@ func pagerProviderHttpServer(t *testing.T) *httptest.Server { } // assertJSON compares the JSON representation of `got` with the golden file. +// The golden file is named conventionally after the full test name with `.golden.json` suffix. // // WARNING: `got` must not refer to data with non-deterministic order. // For example, Go's builtin map is not order-deterministic, thus it might produce inconsistent JSON comparison in this method. @@ -70,5 +79,6 @@ func assertJSON(t *testing.T, got any) { } goldenFile := t.Name() + ".golden.json" + t.Logf("using %s\n", goldenFile) golden.Assert(t, string(b), goldenFile) } diff --git a/pager/pagerduty.go b/pager/pagerduty.go index 5c942b1..e467e0b 100644 --- a/pager/pagerduty.go +++ b/pager/pagerduty.go @@ -3,6 +3,7 @@ package pager import ( "context" "fmt" + "slices" "strconv" "strings" "time" @@ -17,6 +18,12 @@ type PagerDuty struct { client *pagerduty.Client } +var ( + pdTeamInterface string + + pdTeamInterfaces = []string{"team", "service"} +) + func NewPagerDuty(apiKey string) *PagerDuty { return &PagerDuty{ client: pagerduty.NewClient(apiKey), @@ -30,7 +37,256 @@ func NewPagerDutyWithURL(apiKey, url string) *PagerDuty { } func (p *PagerDuty) Kind() string { - return "pagerduty" + return "PagerDuty" +} + +// TeamInterfaces defines the available abstraction of a team from PagerDuty. +// When "team" is selected, the team is fetched from PagerDuty and imported as-is. +// When "service" is selected, a "service team" will be created as a proxy team, linked to regular PagerDuty teams, +// via ext_team_groups table. When populating user members, the service team will query all the linked teams for +// all their user members. +func (p *PagerDuty) TeamInterfaces() []string { + return pdTeamInterfaces +} + +func (p *PagerDuty) UseTeamInterface(interfaceName string) error { + if slices.Contains(pdTeamInterfaces, interfaceName) { + pdTeamInterface = interfaceName + return nil + } + return fmt.Errorf("unknown team interface '%s'", interfaceName) +} + +func (p *PagerDuty) Teams(ctx context.Context) ([]store.ExtTeam, error) { + switch pdTeamInterface { + case "team": + return store.UseQueries(ctx).ListNonGroupExtTeams(ctx) + case "service": + return store.UseQueries(ctx).ListGroupExtTeams(ctx) + case "": + return nil, fmt.Errorf("team interface not set") + default: + return nil, fmt.Errorf("unknown team interface '%s'", pdTeamInterface) + } +} + +func (p *PagerDuty) LoadUsers(ctx context.Context) error { + opts := pagerduty.ListUsersOptions{ + Offset: 0, + } + + for { + resp, err := p.client.ListUsersWithContext(ctx, opts) + if err != nil { + return fmt.Errorf("listing users: %w", err) + } + + for _, user := range resp.Users { + if err := store.UseQueries(ctx).InsertExtUser(ctx, store.InsertExtUserParams{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + }); err != nil { + return fmt.Errorf("saving user to db: %w", err) + } + } + + // Results are paginated, so break if we're on the last page. + if !resp.More { + break + } + opts.Offset += uint(len(resp.Users)) + } + + return nil +} + +func (p *PagerDuty) LoadTeams(ctx context.Context) error { + switch pdTeamInterface { + case "team": + return p.loadTeams(ctx) + case "service": + return p.loadServices(ctx) + case "": + return fmt.Errorf("team interface not set") + default: + return fmt.Errorf("unknown team interface '%s'", pdTeamInterface) + } +} + +func (p *PagerDuty) loadTeams(ctx context.Context) error { + opts := pagerduty.ListTeamOptions{ + Offset: 0, + } + + for { + resp, err := p.client.ListTeamsWithContext(ctx, opts) + if err != nil { + return fmt.Errorf("listing teams: %w", err) + } + + for _, team := range resp.Teams { + if err := store.UseQueries(ctx).InsertExtTeam(ctx, store.InsertExtTeamParams{ + ID: team.ID, + Name: team.Name, + // PagerDuty does not expose slug, so we can safely generate one. + Slug: slug.Make(team.Name), + }); err != nil { + return fmt.Errorf("saving team '%s (%s)' to db: %w", team.Name, team.ID, err) + } + } + + // Results are paginated, so break if we're on the last page. + if !resp.More { + break + } + opts.Offset += uint(len(resp.Teams)) + } + + return nil +} + +func (p *PagerDuty) loadServices(ctx context.Context) error { + opts := pagerduty.ListServiceOptions{ + Includes: []string{"teams"}, + Offset: 0, + } + + q := store.UseQueries(ctx) + + for { + resp, err := p.client.ListServicesWithContext(ctx, opts) + if err != nil { + return fmt.Errorf("listing services: %w", err) + } + + for _, service := range resp.Services { + if err := q.InsertExtTeam(ctx, store.InsertExtTeamParams{ + ID: service.ID, + Name: service.Name, + // PagerDuty does not expose "Slug", so we can safely generate one. + Slug: slug.Make(service.Name), + IsGroup: 1, + }); err != nil { + return fmt.Errorf("saving service '%s (%s)' as team to db: %w", service.Name, service.ID, err) + } + for _, team := range service.Teams { + if err := q.InsertExtTeam(ctx, store.InsertExtTeamParams{ + ID: team.ID, + Name: team.Name, + // PagerDuty does not expose "Slug", so we can safely generate one. + Slug: slug.Make(team.Name), + }); err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint") { + // Assume that team was already imported from another service. + console.Warnf("Team %s has been imported, skipping duplicate...\n", service.ID, team.ID) + } else { + return fmt.Errorf("saving team '%s (%s)' to db: %w", team.Name, team.ID, err) + } + } + if err := q.InsertExtTeamGroup(ctx, store.InsertExtTeamGroupParams{ + GroupTeamID: service.ID, + MemberTeamID: team.ID, + }); err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint") { + // This should never happen, unless it's on a dirty database. Warn users anyway. + console.Warnf("Service %s already has team %s, skipping duplicate...\n", service.ID, team.ID) + } else { + return fmt.Errorf("saving '%s (%s)' team as proxy for '%s (%s)' service: %w", team.Name, team.ID, service.Name, service.ID, err) + } + } + } + } + // Results are paginated, so break if we're on the last page. + if !resp.More { + break + } + opts.Offset += uint(len(resp.Services)) + } + + return nil +} + +func (p *PagerDuty) LoadTeamMembers(ctx context.Context) error { + switch pdTeamInterface { + case "team": + return p.loadTeamMembers(ctx) + case "service": + return p.loadServiceTeamMembers(ctx) + case "": + return fmt.Errorf("team interface not set") + default: + return fmt.Errorf("unknown team interface '%s'", pdTeamInterface) + } +} + +func (p *PagerDuty) loadTeamMembers(ctx context.Context) error { + teams, err := store.UseQueries(ctx).ListTeams(ctx) + if err != nil { + return fmt.Errorf("listing teams: %w", err) + } + + for _, team := range teams { + if err := p.loadMembers(ctx, team.ID); err != nil { + return fmt.Errorf("loading team members: %w", err) + } + } + return nil +} + +func (p *PagerDuty) loadServiceTeamMembers(ctx context.Context) error { + teams, err := store.UseQueries(ctx).ListNonGroupExtTeams(ctx) + if err != nil { + return fmt.Errorf("listing teams: %w", err) + } + + for _, team := range teams { + if err := p.loadMembers(ctx, team.ID); err != nil { + return fmt.Errorf("loading service team members: %w", err) + } + } + return nil +} + +// loadMembers loads members of a team from PagerDuty API and saves them to the database. +// teamID is the team ID which will be used in HTTP query to PagerDuty API, while memberOfTeamID is the +// reference which will be used in database. +func (p *PagerDuty) loadMembers(ctx context.Context, teamID string) error { + // PagerDuty REST API technically supports `includes[]=user` but it's not exposed in Go SDK. + // As such, we currently assume the user is already present in the database and only save the relationship. + opts := pagerduty.ListTeamMembersOptions{ + Offset: 0, + } + q := store.UseQueries(ctx) + + for { + resp, err := p.client.ListTeamMembers(ctx, teamID, opts) + if err != nil { + return err + } + + for _, member := range resp.Members { + if err := q.InsertExtMembership(ctx, store.InsertExtMembershipParams{ + TeamID: teamID, + UserID: member.User.ID, + }); err != nil { + if strings.Contains(err.Error(), "FOREIGN KEY constraint") { + console.Warnf("User %s not found for team %s, skipping...\n", member.User.ID, teamID) + } else if strings.Contains(err.Error(), "UNIQUE constraint") { + console.Warnf("User %s already exists for team %s, skipping duplicate...\n", member.User.ID, teamID) + } else { + return fmt.Errorf("saving team member: %w", err) + } + } + } + + // Results are paginated, so break if we're on the last page. + if !resp.More { + break + } + opts.Offset += uint(len(resp.Members)) + } + return nil } func (p *PagerDuty) LoadSchedules(ctx context.Context) error { @@ -291,100 +547,3 @@ func (p *PagerDuty) saveEscalationPolicyStepTargetToDB( } return nil } - -func (p *PagerDuty) PopulateTeamMembers(ctx context.Context, team *Team) error { - members := []*User{} - opts := pagerduty.ListTeamMembersOptions{ - Offset: 0, - } - - for { - resp, err := p.client.ListTeamMembers(ctx, team.ID, opts) - if err != nil { - return err - } - - for _, member := range resp.Members { - members = append(members, &User{Resource: Resource{ID: member.User.ID}}) - } - - // Results are paginated, so break if we're on the last page. - if !resp.More { - break - } - opts.Offset += uint(len(resp.Members)) - } - team.Members = members - return nil -} - -func (p *PagerDuty) ListTeams(ctx context.Context) ([]*Team, error) { - teams := []*Team{} - opts := pagerduty.ListTeamOptions{ - Offset: 0, - } - - for { - resp, err := p.client.ListTeamsWithContext(ctx, opts) - if err != nil { - return nil, err - } - - for _, team := range resp.Teams { - teams = append(teams, p.toTeam(team)) - } - - // Results are paginated, so break if we're on the last page. - if !resp.More { - break - } - opts.Offset += uint(len(resp.Teams)) - } - return teams, nil -} - -func (p *PagerDuty) toTeam(team pagerduty.Team) *Team { - return &Team{ - // PagerDuty does not expose a slug, so generate one. - Slug: slug.Make(team.Name), - Resource: Resource{ - ID: team.ID, - Name: team.Name, - }, - } -} - -func (p *PagerDuty) ListUsers(ctx context.Context) ([]*User, error) { - users := []*User{} - opts := pagerduty.ListUsersOptions{ - Offset: 0, - } - - for { - resp, err := p.client.ListUsersWithContext(ctx, opts) - if err != nil { - return nil, err - } - - for _, user := range resp.Users { - users = append(users, p.toUser(user)) - } - - // Results are paginated, so break if we're on the last page. - if !resp.More { - break - } - opts.Offset += uint(len(resp.Users)) - } - return users, nil -} - -func (p *PagerDuty) toUser(user pagerduty.User) *User { - return &User{ - Email: user.Email, - Resource: Resource{ - ID: user.ID, - Name: user.Name, - }, - } -} diff --git a/pager/pagerduty_test.go b/pager/pagerduty_test.go index d819368..65e623a 100644 --- a/pager/pagerduty_test.go +++ b/pager/pagerduty_test.go @@ -17,26 +17,119 @@ func TestPagerDuty(t *testing.T) { // Avoid sharing setup code between tests to prevent test pollution in parallel execution. setup := func(t *testing.T) (context.Context, pager.Pager) { - t.Parallel() - ctx := withTestDB(t) ts := pagerProviderHttpServer(t) pd := pager.NewPagerDutyWithURL("api-key-very-secret", ts.URL) return ctx, pd } - t.Run("ListUsers", func(t *testing.T) { + t.Run("LoadUsers", func(t *testing.T) { + t.Parallel() ctx, pd := setup(t) - u, err := pd.ListUsers(ctx) + if err := pd.LoadUsers(ctx); err != nil { + t.Fatalf("error loading users: %s", err) + } + + u, err := store.UseQueries(ctx).ListExtUsers(ctx) if err != nil { t.Fatalf("error loading users: %s", err) } - t.Logf("found %d users", len(u)) assertJSON(t, u) }) + // LoadTeams has 2 variants: one for literal teams and another for importing services as teams. + // The "state" is maintained globally and as such should not be run in parallel. + t.Run("LoadTeams", func(t *testing.T) { + t.Run("loadTeams", func(t *testing.T) { + ctx, pd := setup(t) + + if err := pd.UseTeamInterface("team"); err != nil { + t.Fatalf("error setting team interface: %s", err) + } + if err := pd.LoadTeams(ctx); err != nil { + t.Fatalf("error loading teams: %s", err) + } + teams, err := store.UseQueries(ctx).ListExtTeams(ctx) + if err != nil { + t.Fatalf("error loading teams: %s", err) + } + t.Logf("found %d teams", len(teams)) + assertJSON(t, teams) + }) + + t.Run("loadServices", func(t *testing.T) { + ctx, pd := setup(t) + + if err := pd.UseTeamInterface("service"); err != nil { + t.Fatalf("error setting team interface: %s", err) + } + if err := pd.LoadTeams(ctx); err != nil { + t.Fatalf("error loading teams: %s", err) + } + teams, err := store.UseQueries(ctx).ListExtTeams(ctx) + if err != nil { + t.Fatalf("error loading teams: %s", err) + } + t.Logf("found %d teams, including services", len(teams)) + + // We have 2 services: + // - Endeavour, which has a team: Page Responder Team + // - Server under Jack's desk, which has a team: Jack's team + // We expect the membership association to link the users with the services, not their immediate team. + assertJSON(t, teams) + }) + }) + + t.Run("LoadTeamMembers", func(t *testing.T) { + t.Run("loadTeamMembers", func(t *testing.T) { + ctx, pd := setup(t) + + if err := pd.UseTeamInterface("team"); err != nil { + t.Fatalf("error setting team interface: %s", err) + } + if err := pd.LoadUsers(ctx); err != nil { + t.Fatalf("error loading users: %s", err) + } + if err := pd.LoadTeams(ctx); err != nil { + t.Fatalf("error loading teams: %s", err) + } + if err := pd.LoadTeamMembers(ctx); err != nil { + t.Fatalf("error loading team members: %s", err) + } + members, err := store.UseQueries(ctx).ListExtTeamMemberships(ctx) + if err != nil { + t.Fatalf("error loading team members: %s", err) + } + t.Logf("found %d team members", len(members)) + assertJSON(t, members) + }) + t.Run("loadServiceMembers", func(t *testing.T) { + ctx, pd := setup(t) + + if err := pd.UseTeamInterface("service"); err != nil { + t.Fatalf("error setting team interface: %s", err) + } + if err := pd.LoadUsers(ctx); err != nil { + t.Fatalf("error loading users: %s", err) + } + if err := pd.LoadTeams(ctx); err != nil { + t.Fatalf("error loading teams: %s", err) + } + if err := pd.LoadTeamMembers(ctx); err != nil { + t.Fatalf("error loading team members: %s", err) + } + members, err := store.UseQueries(ctx).ListGroupExtTeamMemberships(ctx) + if err != nil { + t.Fatalf("error loading team members: %s", err) + } + t.Logf("found %d team members, including services", len(members)) + assertJSON(t, members) + }) + }) + t.Run("LoadSchedules", func(t *testing.T) { + t.Parallel() ctx, pd := setup(t) if err := pd.LoadSchedules(ctx); err != nil { diff --git a/pager/testdata/TestOpsgenie/ListUsers.golden.json b/pager/testdata/TestOpsgenie/ListUsers.golden.json deleted file mode 100644 index 67861b2..0000000 --- a/pager/testdata/TestOpsgenie/ListUsers.golden.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "Email": "john.doe@opsgenie.com", - "ID": "b5b92115-bfe7-43eb-8c2a-e467f2e5ddc4", - "Name": "john doe", - "Description": "" - }, - { - "Email": "jane.doe@opsgenie.com", - "ID": "b5b92115-bfe7-43eb-8c2a-e467f2e5ddc5", - "Name": "jane doe", - "Description": "" - } -] diff --git a/pager/testdata/TestOpsgenie/LoadUsers.golden.json b/pager/testdata/TestOpsgenie/LoadUsers.golden.json new file mode 100644 index 0000000..274b559 --- /dev/null +++ b/pager/testdata/TestOpsgenie/LoadUsers.golden.json @@ -0,0 +1,20 @@ +[ + { + "id": "b5b92115-bfe7-43eb-8c2a-e467f2e5ddc4", + "name": "john doe", + "email": "john.doe@opsgenie.com", + "fh_user_id": { + "String": "", + "Valid": false + } + }, + { + "id": "b5b92115-bfe7-43eb-8c2a-e467f2e5ddc5", + "name": "jane doe", + "email": "jane.doe@opsgenie.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } +] diff --git a/pager/testdata/TestOpsgenie/apiserver/v2-schedules-3fee43f2-02da-49be-ab50-c88ed13aecc3.json b/pager/testdata/TestOpsgenie/apiserver/v2-schedules-3fee43f2-02da-49be-ab50-c88ed13aecc3-identifiertype-id.json similarity index 100% rename from pager/testdata/TestOpsgenie/apiserver/v2-schedules-3fee43f2-02da-49be-ab50-c88ed13aecc3.json rename to pager/testdata/TestOpsgenie/apiserver/v2-schedules-3fee43f2-02da-49be-ab50-c88ed13aecc3-identifiertype-id.json diff --git a/pager/testdata/TestOpsgenie/apiserver/v2-schedules.json b/pager/testdata/TestOpsgenie/apiserver/v2-schedules-expand-rotation.json similarity index 100% rename from pager/testdata/TestOpsgenie/apiserver/v2-schedules.json rename to pager/testdata/TestOpsgenie/apiserver/v2-schedules-expand-rotation.json diff --git a/pager/testdata/TestPagerDuty/ListUsers.golden.json b/pager/testdata/TestPagerDuty/ListUsers.golden.json deleted file mode 100644 index bbefdf8..0000000 --- a/pager/testdata/TestPagerDuty/ListUsers.golden.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "Email": "mika+eng@example.com", - "ID": "P5A1XH2", - "Name": "Mika", - "Description": "" - }, - { - "Email": "horse+eng@example.com", - "ID": "PRXEEQ8", - "Name": "Horse", - "Description": "" - }, - { - "Email": "acme-eng@example.com", - "ID": "PXI6XNI", - "Name": "Acme Engineering", - "Description": "" - }, - { - "Email": "kiran+eng@example.com", - "ID": "P8ZZ1ZB", - "Name": "Kiran", - "Description": "" - }, - { - "Email": "acme-success+eng@example.com", - "ID": "P2C9LBA", - "Name": "Wong", - "Description": "" - }, - { - "Email": "jack+eng@example.com", - "ID": "P4CMCAU", - "Name": "Jack T.", - "Description": "" - } -] diff --git a/pager/testdata/TestPagerDuty/LoadTeamMembers/loadServiceMembers.golden.json b/pager/testdata/TestPagerDuty/LoadTeamMembers/loadServiceMembers.golden.json new file mode 100644 index 0000000..899f524 --- /dev/null +++ b/pager/testdata/TestPagerDuty/LoadTeamMembers/loadServiceMembers.golden.json @@ -0,0 +1,46 @@ +[ + { + "ext_team": { + "id": "P4XMRL3", + "name": "Endeavour", + "slug": "endeavour", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 1, + "to_import": 0 + }, + "ext_user": { + "id": "PXI6XNI", + "name": "Acme Engineering", + "email": "acme-eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + }, + { + "ext_team": { + "id": "P3IIAF1", + "name": "Server under Jack's desk", + "slug": "server-under-jacks-desk", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 1, + "to_import": 0 + }, + "ext_user": { + "id": "P4CMCAU", + "name": "Jack T.", + "email": "jack+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + } +] diff --git a/pager/testdata/TestPagerDuty/LoadTeamMembers/loadTeamMembers.golden.json b/pager/testdata/TestPagerDuty/LoadTeamMembers/loadTeamMembers.golden.json new file mode 100644 index 0000000..c74181b --- /dev/null +++ b/pager/testdata/TestPagerDuty/LoadTeamMembers/loadTeamMembers.golden.json @@ -0,0 +1,156 @@ +[ + { + "ext_team": { + "id": "PT54U20", + "name": "Jen", + "slug": "jen", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + "ext_user": { + "id": "PXI6XNI", + "name": "Acme Engineering", + "email": "acme-eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + }, + { + "ext_team": { + "id": "PT54U20", + "name": "Jen", + "slug": "jen", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + "ext_user": { + "id": "P2C9LBA", + "name": "Wong", + "email": "acme-success+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + }, + { + "ext_team": { + "id": "PO34CI9", + "name": "CS-Team-Test", + "slug": "cs-team-test", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + "ext_user": { + "id": "PXI6XNI", + "name": "Acme Engineering", + "email": "acme-eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + }, + { + "ext_team": { + "id": "P5PH8KY", + "name": "Page Responder Team", + "slug": "page-responder-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + "ext_user": { + "id": "PXI6XNI", + "name": "Acme Engineering", + "email": "acme-eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + }, + { + "ext_team": { + "id": "PV9JOXL", + "name": "Service Catalog Team", + "slug": "service-catalog-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + "ext_user": { + "id": "PRXEEQ8", + "name": "Horse", + "email": "horse+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + }, + { + "ext_team": { + "id": "PV9JOXL", + "name": "Service Catalog Team", + "slug": "service-catalog-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + "ext_user": { + "id": "PXI6XNI", + "name": "Acme Engineering", + "email": "acme-eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + }, + { + "ext_team": { + "id": "PD2F80U", + "name": "Jack Team", + "slug": "jack-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + "ext_user": { + "id": "P4CMCAU", + "name": "Jack T.", + "email": "jack+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } + } +] diff --git a/pager/testdata/TestPagerDuty/LoadTeams/loadServices.golden.json b/pager/testdata/TestPagerDuty/LoadTeams/loadServices.golden.json new file mode 100644 index 0000000..7e53127 --- /dev/null +++ b/pager/testdata/TestPagerDuty/LoadTeams/loadServices.golden.json @@ -0,0 +1,46 @@ +[ + { + "id": "P4XMRL3", + "name": "Endeavour", + "slug": "endeavour", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 1, + "to_import": 0 + }, + { + "id": "P5PH8KY", + "name": "Page Responder Team", + "slug": "page-responder-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + { + "id": "P3IIAF1", + "name": "Server under Jack's desk", + "slug": "server-under-jacks-desk", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 1, + "to_import": 0 + }, + { + "id": "PD2F80U", + "name": "Jack Team", + "slug": "jack-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + } +] diff --git a/pager/testdata/TestPagerDuty/LoadTeams/loadTeams.golden.json b/pager/testdata/TestPagerDuty/LoadTeams/loadTeams.golden.json new file mode 100644 index 0000000..71169ba --- /dev/null +++ b/pager/testdata/TestPagerDuty/LoadTeams/loadTeams.golden.json @@ -0,0 +1,68 @@ +[ + { + "id": "PT54U20", + "name": "Jen", + "slug": "jen", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + { + "id": "PO206TE", + "name": "canary-team", + "slug": "canary-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + { + "id": "PO34CI9", + "name": "CS-Team-Test", + "slug": "cs-team-test", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + { + "id": "P5PH8KY", + "name": "Page Responder Team", + "slug": "page-responder-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + { + "id": "PV9JOXL", + "name": "Service Catalog Team", + "slug": "service-catalog-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + }, + { + "id": "PD2F80U", + "name": "Jack Team", + "slug": "jack-team", + "fh_team_id": { + "String": "", + "Valid": false + }, + "is_group": 0, + "to_import": 0 + } +] diff --git a/pager/testdata/TestPagerDuty/LoadUsers.golden.json b/pager/testdata/TestPagerDuty/LoadUsers.golden.json new file mode 100644 index 0000000..cfee870 --- /dev/null +++ b/pager/testdata/TestPagerDuty/LoadUsers.golden.json @@ -0,0 +1,56 @@ +[ + { + "id": "P5A1XH2", + "name": "Mika", + "email": "mika+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + }, + { + "id": "PRXEEQ8", + "name": "Horse", + "email": "horse+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + }, + { + "id": "PXI6XNI", + "name": "Acme Engineering", + "email": "acme-eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + }, + { + "id": "P8ZZ1ZB", + "name": "Kiran", + "email": "kiran+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + }, + { + "id": "P2C9LBA", + "name": "Wong", + "email": "acme-success+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + }, + { + "id": "P4CMCAU", + "name": "Jack T.", + "email": "jack+eng@example.com", + "fh_user_id": { + "String": "", + "Valid": false + } + } +] diff --git a/pager/testdata/TestPagerDuty/apiserver/schedules.json b/pager/testdata/TestPagerDuty/apiserver/schedules-include-5b-5d-schedule_layers.json similarity index 97% rename from pager/testdata/TestPagerDuty/apiserver/schedules.json rename to pager/testdata/TestPagerDuty/apiserver/schedules-include-5b-5d-schedule_layers.json index cdbca72..7fade86 100644 --- a/pager/testdata/TestPagerDuty/apiserver/schedules.json +++ b/pager/testdata/TestPagerDuty/apiserver/schedules-include-5b-5d-schedule_layers.json @@ -89,11 +89,11 @@ "summary": "Jen - primary", "teams": [ { - "html_url": "https://acme-inc.pagerduty.com/teams/PT54U20", - "id": "PT54U20", - "self": "https://api.pagerduty.com/teams/PT54U20", - "summary": "Jen", - "type": "team_reference" + "id": "P5PH8KY", + "summary": "Page Responder Team", + "type": "team_reference", + "self": "https://api.pagerduty.com/teams/P5PH8KY", + "html_url": "https://acme-inc.pagerduty.com/teams/P5PH8KY" } ], "time_zone": "America/Los_Angeles", diff --git a/pager/testdata/TestPagerDuty/apiserver/services-include-5b-5d-teams.json b/pager/testdata/TestPagerDuty/apiserver/services-include-5b-5d-teams.json new file mode 100644 index 0000000..01a72a6 --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/services-include-5b-5d-teams.json @@ -0,0 +1,59 @@ +{ + "services": [ + { + "id": "P4XMRL3", + "name": "Endeavour", + "description": null, + "created_at": "2021-01-07T08:34:46-08:00", + "updated_at": "2021-01-07T08:34:46-08:00", + "status": "active", + "teams": [ + { + "id": "P5PH8KY", + "name": "Page Responder Team", + "description": null, + "type": "team", + "summary": "Page Responder Team", + "self": "https://api.pagerduty.com/teams/P5PH8KY", + "html_url": "https://acme-inc.pagerduty.com/teams/P5PH8KY", + "default_role": "manager", + "parent": null + } + ], + "alert_creation": "create_alerts_and_incidents", + "addons": [], + "scheduled_actions": [], + "support_hours": null, + "last_incident_timestamp": null, + "escalation_policy": { + "id": "P6F7EI2", + "type": "escalation_policy_reference", + "summary": "Endeavour", + "self": "https://api.pagerduty.com/escalation_policies/P6F7EI2", + "html_url": "https://pdt-apidocs.pagerduty.com/escalation_policies/P6F7EI2" + }, + "incident_urgency_rule": { + "type": "constant", + "urgency": "high" + }, + "acknowledgement_timeout": null, + "auto_resolve_timeout": null, + "alert_grouping": null, + "alert_grouping_timeout": null, + "alert_grouping_parameters": { + "type": null, + "config": null + }, + "integrations": [], + "response_play": null, + "type": "service", + "summary": "Endeavour", + "self": "https://api.pagerduty.com/services/P18JCJH", + "html_url": "https://pdt-apidocs.pagerduty.com/service-directory/P18JCJH" + } + ], + "limit": 25, + "offset": 0, + "total": null, + "more": true +} diff --git a/pager/testdata/TestPagerDuty/apiserver/services-include-5b-5d-teamsandoffset-1.json b/pager/testdata/TestPagerDuty/apiserver/services-include-5b-5d-teamsandoffset-1.json new file mode 100644 index 0000000..1e0bb88 --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/services-include-5b-5d-teamsandoffset-1.json @@ -0,0 +1,62 @@ +{ + "services": [ + { + "id": "P3IIAF1", + "name": "Server under Jack's desk", + "description": "Demo Service", + "created_at": "2023-01-04T11:56:38-08:00", + "updated_at": "2023-01-04T11:56:38-08:00", + "status": "active", + "teams": [ + { + "default_role": "manager", + "description": null, + "html_url": "https://acme-inc.pagerduty.com/teams/PD2F80U", + "id": "PD2F80U", + "name": "Jack Team", + "parent": null, + "self": "https://api.pagerduty.com/teams/PD2F80U", + "summary": "Jack Team", + "type": "team" + } + ], + "alert_creation": "create_alerts_and_incidents", + "addons": [], + "scheduled_actions": [], + "support_hours": null, + "last_incident_timestamp": "2023-01-04T19:57:38Z", + "escalation_policy": { + "id": "PT25XJK", + "type": "escalation_policy_reference", + "summary": "GooglePDService-ep", + "self": "https://api.pagerduty.com/escalation_policies/PT25XJK", + "html_url": "https://pdt-apidocs.pagerduty.com/escalation_policies/PT25XJK" + }, + "incident_urgency_rule": { + "type": "constant", + "urgency": "high" + }, + "acknowledgement_timeout": null, + "auto_resolve_timeout": null, + "alert_grouping": "intelligent", + "alert_grouping_timeout": null, + "alert_grouping_parameters": { + "type": "intelligent", + "config": { + "time_window": 300, + "recommended_time_window": 300 + } + }, + "integrations": [], + "response_play": null, + "type": "service", + "summary": "GooglePDService", + "self": "https://api.pagerduty.com/services/PU1BXAX", + "html_url": "https://pdt-apidocs.pagerduty.com/service-directory/PU1BXAX" + } + ], + "limit": 25, + "offset": 0, + "total": null, + "more": false +} diff --git a/pager/testdata/TestPagerDuty/apiserver/teams-p5ph8ky-members.json b/pager/testdata/TestPagerDuty/apiserver/teams-p5ph8ky-members.json new file mode 100644 index 0000000..ba2d616 --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/teams-p5ph8ky-members.json @@ -0,0 +1,18 @@ +{ + "members": [ + { + "user": { + "id": "PXI6XNI", + "type": "user_reference", + "summary": "Acme Engineering", + "self": "https://api.pagerduty.com/users/PXI6XNI", + "html_url": "https://acme-eng.pagerduty.com/users/PXI6XNI" + }, + "role": "manager" + } + ], + "limit": 100, + "offset": 0, + "more": false, + "total": null +} diff --git a/pager/testdata/TestPagerDuty/apiserver/teams-pd2f80u-members.json b/pager/testdata/TestPagerDuty/apiserver/teams-pd2f80u-members.json new file mode 100644 index 0000000..528558e --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/teams-pd2f80u-members.json @@ -0,0 +1,18 @@ +{ + "members": [ + { + "user": { + "id": "P4CMCAU", + "type": "user_reference", + "summary": "Jack T", + "self": "https://api.pagerduty.com/users/P4CMCAU", + "html_url": "https://acme-eng.pagerduty.com/users/P4CMCAU" + }, + "role": "manager" + } + ], + "limit": 100, + "offset": 0, + "more": false, + "total": null +} diff --git a/pager/testdata/TestPagerDuty/apiserver/teams-po206te-members.json b/pager/testdata/TestPagerDuty/apiserver/teams-po206te-members.json new file mode 100644 index 0000000..5624b65 --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/teams-po206te-members.json @@ -0,0 +1,7 @@ +{ + "limit": 100, + "members": [], + "more": false, + "offset": 0, + "total": null +} diff --git a/pager/testdata/TestPagerDuty/apiserver/teams-po34ci9-members.json b/pager/testdata/TestPagerDuty/apiserver/teams-po34ci9-members.json new file mode 100644 index 0000000..ba2d616 --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/teams-po34ci9-members.json @@ -0,0 +1,18 @@ +{ + "members": [ + { + "user": { + "id": "PXI6XNI", + "type": "user_reference", + "summary": "Acme Engineering", + "self": "https://api.pagerduty.com/users/PXI6XNI", + "html_url": "https://acme-eng.pagerduty.com/users/PXI6XNI" + }, + "role": "manager" + } + ], + "limit": 100, + "offset": 0, + "more": false, + "total": null +} diff --git a/pager/testdata/TestPagerDuty/apiserver/teams-pt54u20-members.json b/pager/testdata/TestPagerDuty/apiserver/teams-pt54u20-members.json new file mode 100644 index 0000000..b5fd3f7 --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/teams-pt54u20-members.json @@ -0,0 +1,28 @@ +{ + "members": [ + { + "user": { + "id": "PXI6XNI", + "type": "user_reference", + "summary": "Acme Engineering", + "self": "https://api.pagerduty.com/users/PXI6XNI", + "html_url": "https://acme-eng.pagerduty.com/users/PXI6XNI" + }, + "role": "manager" + }, + { + "user": { + "id": "P2C9LBA", + "type": "user_reference", + "summary": "This user exists, technically", + "self": "https://api.pagerduty.com/users/P2C9LBA", + "html_url": "https://acme-eng.pagerduty.com/users/P2C9LBA" + }, + "role": "manager" + } + ], + "limit": 100, + "offset": 0, + "more": false, + "total": null +} diff --git a/pager/testdata/TestPagerDuty/apiserver/teams-pv9joxl-members.json b/pager/testdata/TestPagerDuty/apiserver/teams-pv9joxl-members.json new file mode 100644 index 0000000..cdfa524 --- /dev/null +++ b/pager/testdata/TestPagerDuty/apiserver/teams-pv9joxl-members.json @@ -0,0 +1,28 @@ +{ + "members": [ + { + "user": { + "id": "PRXEEQ8", + "type": "user_reference", + "summary": "Horse", + "self": "https://api.pagerduty.com/users/PRXEEQ8", + "html_url": "https://acme-eng.pagerduty.com/users/PRXEEQ8" + }, + "role": "manager" + }, + { + "user": { + "id": "PXI6XNI", + "type": "user_reference", + "summary": "Acme Engineering", + "self": "https://api.pagerduty.com/users/PXI6XNI", + "html_url": "https://acme-eng.pagerduty.com/users/PXI6XNI" + }, + "role": "manager" + } + ], + "limit": 100, + "offset": 0, + "more": false, + "total": null +} diff --git a/pager/testdata/TestPagerDuty/apiserver/teams.json b/pager/testdata/TestPagerDuty/apiserver/teams.json index 32e54d4..cbf6cb0 100644 --- a/pager/testdata/TestPagerDuty/apiserver/teams.json +++ b/pager/testdata/TestPagerDuty/apiserver/teams.json @@ -36,39 +36,6 @@ "summary": "CS-Team-Test", "type": "team" }, - { - "default_role": "manager", - "description": null, - "html_url": "https://acme-inc.pagerduty.com/teams/PEGBTKE", - "id": "PEGBTKE", - "name": "CS-Test-Alerting", - "parent": null, - "self": "https://api.pagerduty.com/teams/PEGBTKE", - "summary": "CS-Test-Alerting", - "type": "team" - }, - { - "default_role": "manager", - "description": null, - "html_url": "https://acme-inc.pagerduty.com/teams/P7E9UET", - "id": "P7E9UET", - "name": "caroline-team-test", - "parent": null, - "self": "https://api.pagerduty.com/teams/P7E9UET", - "summary": "caroline-team-test", - "type": "team" - }, - { - "default_role": "manager", - "description": null, - "html_url": "https://acme-inc.pagerduty.com/teams/P7A9Q6H", - "id": "P7A9Q6H", - "name": "operational-team-test", - "parent": null, - "self": "https://api.pagerduty.com/teams/P7A9Q6H", - "summary": "operational-team-test", - "type": "team" - }, { "default_role": "manager", "description": null, diff --git a/pager/testdata/TestPagerDuty/apiserver/users.json b/pager/testdata/TestPagerDuty/apiserver/users.json index a6a1826..47e3848 100644 --- a/pager/testdata/TestPagerDuty/apiserver/users.json +++ b/pager/testdata/TestPagerDuty/apiserver/users.json @@ -256,13 +256,6 @@ "self": "https://api.pagerduty.com/users/P2C9LBA", "summary": "Wong", "teams": [ - { - "html_url": "https://acme-inc.pagerduty.com/teams/PEGBTKE", - "id": "PEGBTKE", - "self": "https://api.pagerduty.com/teams/PEGBTKE", - "summary": "CS-Test-Alerting", - "type": "team_reference" - }, { "html_url": "https://acme-inc.pagerduty.com/teams/PT54U20", "id": "PT54U20", diff --git a/pager/victorops.go b/pager/victorops.go index 440a03c..d119ebd 100644 --- a/pager/victorops.go +++ b/pager/victorops.go @@ -4,6 +4,7 @@ import ( "context" "github.com/firehydrant/signals-migrator/console" + "github.com/firehydrant/signals-migrator/store" "github.com/gosimple/slug" "github.com/victorops/go-victorops/victorops" ) @@ -19,7 +20,35 @@ func NewVictorOps(apiKey string, appId string) *VictorOps { } func (v *VictorOps) Kind() string { - return "victorops" + return "VictorOps" +} + +func (v *VictorOps) TeamInterfaces() []string { + return []string{"team"} +} + +func (v *VictorOps) UseTeamInterface(string) error { + return nil +} + +func (v *VictorOps) Teams(ctx context.Context) ([]store.ExtTeam, error) { + return store.UseQueries(ctx).ListExtTeams(ctx) +} + +func (v *VictorOps) LoadUsers(ctx context.Context) error { + console.Warnf("victorops.LoadUsers is not currently supported.") + return nil +} + +func (v *VictorOps) LoadTeams(ctx context.Context) error { + // TODO: implement + console.Warnf("victorops.LoadTeams is not currently supported.") + return nil +} + +func (v *VictorOps) LoadTeamMembers(ctx context.Context) error { + console.Warnf("victorops.LoadTeamMembers is not currently supported.") + return nil } func (v *VictorOps) LoadSchedules(ctx context.Context) error { diff --git a/store/models.go b/store/models.go index 6633ca6..baa7127 100644 --- a/store/models.go +++ b/store/models.go @@ -74,6 +74,13 @@ type ExtTeam struct { Name string `json:"name"` Slug string `json:"slug"` FhTeamID sql.NullString `json:"fh_team_id"` + IsGroup int64 `json:"is_group"` + ToImport int64 `json:"to_import"` +} + +type ExtTeamGroup struct { + GroupTeamID string `json:"group_team_id"` + MemberTeamID string `json:"member_team_id"` } type ExtUser struct { @@ -100,6 +107,8 @@ type LinkedTeam struct { Name string `json:"name"` Slug string `json:"slug"` FhTeamID sql.NullString `json:"fh_team_id"` + IsGroup int64 `json:"is_group"` + ToImport int64 `json:"to_import"` FhName sql.NullString `json:"fh_name"` FhSlug sql.NullString `json:"fh_slug"` } diff --git a/store/open.go b/store/open.go index f3b77b4..6b25b46 100644 --- a/store/open.go +++ b/store/open.go @@ -9,14 +9,18 @@ import ( _ "modernc.org/sqlite" ) -func NewStore() *Store { - db, err := sql.Open("sqlite", "file::memory:?cache=shared") +func NewStore(dsn string) *Store { + db, err := sql.Open("sqlite", dsn) if err != nil { panic(err) } return &Store{conn: db} } +func NewMemoryStore() *Store { + return NewStore("file::memory:?cache=shared") +} + func (s *Store) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { return s.conn.ExecContext(ctx, query, args...) } diff --git a/store/open_dev.go b/store/open_dev.go index a7500bd..bf7ab8e 100644 --- a/store/open_dev.go +++ b/store/open_dev.go @@ -16,17 +16,21 @@ import ( "github.com/fatih/color" ) -func NewStore() *Store { - f := filepath.Join(os.TempDir(), "signals-migrator.db") - log.Printf("using db file: %s", f) - - db, err := sql.Open("sqlite", f) +func NewStore(dsn string) *Store { + db, err := sql.Open("sqlite", dsn) if err != nil { panic(err) } return &Store{conn: db} } +func NewMemoryStore() *Store { + f := filepath.Join(os.TempDir(), "signals-migrator.db") + log.Printf("using db file: %s", f) + + return NewStore(f) +} + func (s *Store) log(t time.Duration, queryStr string) { qInfo := strings.SplitN(queryStr, "\n", 2) name := strings.TrimSpace(qInfo[0]) diff --git a/store/queries.sql b/store/queries.sql index 52c73c8..af3545f 100644 --- a/store/queries.sql +++ b/store/queries.sql @@ -16,6 +16,17 @@ SELECT * FROM fh_teams; -- name: InsertFhTeam :exec INSERT INTO fh_teams (id, name, slug) VALUES (?, ?, ?); +-- name: ListUsersJoinByEmail :many +SELECT sqlc.embed(ext_users), sqlc.embed(fh_users) FROM ext_users + JOIN fh_users ON fh_users.email = ext_users.email; + +-- name: ListExtUsers :many +SELECT * FROM ext_users; + +-- name: ListUnmatchedExtUsers :many +SELECT * FROM ext_users +WHERE fh_user_id IS NULL; + -- name: InsertExtUser :exec INSERT INTO ext_users (id, name, email, fh_user_id) VALUES (?, ?, ?, ?); @@ -25,11 +36,35 @@ SELECT * FROM linked_teams WHERE id = ?; -- name: ListTeams :many SELECT * from linked_teams; --- name: CheckExtTeamExists :one -SELECT COUNT(*) > 0 FROM ext_teams WHERE id = ?; +-- name: ListTeamsToImport :many +SELECT * from linked_teams WHERE to_import = 1; + +-- name: ListExtTeams :many +SELECT * FROM ext_teams; -- name: InsertExtTeam :exec -INSERT INTO ext_teams (id, name, slug, fh_team_id) VALUES (?, ?, ?, ?); +INSERT INTO ext_teams (id, name, slug, is_group, fh_team_id) VALUES (?, ?, ?, ?, ?); + +-- name: MarkExtTeamToImport :exec +UPDATE ext_teams SET to_import = 1 WHERE id = ?; + +-- name: ListGroupExtTeams :many +SELECT * FROM ext_teams +WHERE is_group = 1; + +-- name: ListNonGroupExtTeams :many +SELECT * FROM ext_teams +WHERE is_group = 0; + +-- name: ListMemberExtTeams :many +SELECT * FROM ext_teams +WHERE id IN ( + SELECT DISTINCT member_team_id FROM ext_team_groups + WHERE group_team_id = ? +); + +-- name: InsertExtTeamGroup :exec +INSERT INTO ext_team_groups (group_team_id, member_team_id) VALUES (?, ?); -- name: LinkExtUser :exec UPDATE ext_users SET fh_user_id = ? WHERE id = ?; @@ -37,6 +72,18 @@ UPDATE ext_users SET fh_user_id = ? WHERE id = ?; -- name: LinkExtTeam :exec UPDATE ext_teams SET fh_team_id = ? WHERE id = ?; +-- name: ListExtTeamMemberships :many +SELECT sqlc.embed(ext_teams), sqlc.embed(ext_users) FROM ext_memberships + JOIN ext_teams ON ext_teams.id = ext_memberships.team_id + JOIN ext_users ON ext_users.id = ext_memberships.user_id; + +-- name: ListGroupExtTeamMemberships :many +SELECT sqlc.embed(ext_teams), sqlc.embed(ext_users) FROM ext_teams + JOIN ext_team_groups ON ext_team_groups.group_team_id = ext_teams.id + JOIN ext_teams AS member_team ON member_team.id = ext_team_groups.member_team_id + JOIN ext_memberships ON ext_memberships.team_id = member_team.id + JOIN ext_users ON ext_users.id = ext_memberships.user_id; + -- name: InsertExtMembership :exec INSERT INTO ext_memberships (user_id, team_id) VALUES (?, ?); diff --git a/store/queries.sql.go b/store/queries.sql.go index 6dc61ec..fef003b 100644 --- a/store/queries.sql.go +++ b/store/queries.sql.go @@ -10,17 +10,6 @@ import ( "database/sql" ) -const checkExtTeamExists = `-- name: CheckExtTeamExists :one -SELECT COUNT(*) > 0 FROM ext_teams WHERE id = ? -` - -func (q *Queries) CheckExtTeamExists(ctx context.Context, id string) (bool, error) { - row := q.db.QueryRowContext(ctx, checkExtTeamExists, id) - var column_1 bool - err := row.Scan(&column_1) - return column_1, err -} - const deleteExtEscalationPolicyUnimported = `-- name: DeleteExtEscalationPolicyUnimported :exec DELETE FROM ext_escalation_policies WHERE to_import = 0 ` @@ -63,7 +52,7 @@ func (q *Queries) GetFhUserByEmail(ctx context.Context, email string) (FhUser, e } const getTeamByExtID = `-- name: GetTeamByExtID :one -SELECT id, name, slug, fh_team_id, fh_name, fh_slug FROM linked_teams WHERE id = ? +SELECT id, name, slug, fh_team_id, is_group, to_import, fh_name, fh_slug FROM linked_teams WHERE id = ? ` func (q *Queries) GetTeamByExtID(ctx context.Context, id string) (LinkedTeam, error) { @@ -74,6 +63,8 @@ func (q *Queries) GetTeamByExtID(ctx context.Context, id string) (LinkedTeam, er &i.Name, &i.Slug, &i.FhTeamID, + &i.IsGroup, + &i.ToImport, &i.FhName, &i.FhSlug, ) @@ -271,13 +262,14 @@ func (q *Queries) InsertExtScheduleTeam(ctx context.Context, arg InsertExtSchedu } const insertExtTeam = `-- name: InsertExtTeam :exec -INSERT INTO ext_teams (id, name, slug, fh_team_id) VALUES (?, ?, ?, ?) +INSERT INTO ext_teams (id, name, slug, is_group, fh_team_id) VALUES (?, ?, ?, ?, ?) ` type InsertExtTeamParams struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` + IsGroup int64 `json:"is_group"` FhTeamID sql.NullString `json:"fh_team_id"` } @@ -286,11 +278,26 @@ func (q *Queries) InsertExtTeam(ctx context.Context, arg InsertExtTeamParams) er arg.ID, arg.Name, arg.Slug, + arg.IsGroup, arg.FhTeamID, ) return err } +const insertExtTeamGroup = `-- name: InsertExtTeamGroup :exec +INSERT INTO ext_team_groups (group_team_id, member_team_id) VALUES (?, ?) +` + +type InsertExtTeamGroupParams struct { + GroupTeamID string `json:"group_team_id"` + MemberTeamID string `json:"member_team_id"` +} + +func (q *Queries) InsertExtTeamGroup(ctx context.Context, arg InsertExtTeamGroupParams) error { + _, err := q.db.ExecContext(ctx, insertExtTeamGroup, arg.GroupTeamID, arg.MemberTeamID) + return err +} + const insertExtUser = `-- name: InsertExtUser :exec INSERT INTO ext_users (id, name, email, fh_user_id) VALUES (?, ?, ?, ?) ` @@ -540,6 +547,117 @@ func (q *Queries) ListExtSchedules(ctx context.Context) ([]ExtSchedule, error) { return items, nil } +const listExtTeamMemberships = `-- name: ListExtTeamMemberships :many +SELECT ext_teams.id, ext_teams.name, ext_teams.slug, ext_teams.fh_team_id, ext_teams.is_group, ext_teams.to_import, ext_users.id, ext_users.name, ext_users.email, ext_users.fh_user_id FROM ext_memberships + JOIN ext_teams ON ext_teams.id = ext_memberships.team_id + JOIN ext_users ON ext_users.id = ext_memberships.user_id +` + +type ListExtTeamMembershipsRow struct { + ExtTeam ExtTeam `json:"ext_team"` + ExtUser ExtUser `json:"ext_user"` +} + +func (q *Queries) ListExtTeamMemberships(ctx context.Context) ([]ListExtTeamMembershipsRow, error) { + rows, err := q.db.QueryContext(ctx, listExtTeamMemberships) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListExtTeamMembershipsRow + for rows.Next() { + var i ListExtTeamMembershipsRow + if err := rows.Scan( + &i.ExtTeam.ID, + &i.ExtTeam.Name, + &i.ExtTeam.Slug, + &i.ExtTeam.FhTeamID, + &i.ExtTeam.IsGroup, + &i.ExtTeam.ToImport, + &i.ExtUser.ID, + &i.ExtUser.Name, + &i.ExtUser.Email, + &i.ExtUser.FhUserID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listExtTeams = `-- name: ListExtTeams :many +SELECT id, name, slug, fh_team_id, is_group, to_import FROM ext_teams +` + +func (q *Queries) ListExtTeams(ctx context.Context) ([]ExtTeam, error) { + rows, err := q.db.QueryContext(ctx, listExtTeams) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ExtTeam + for rows.Next() { + var i ExtTeam + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.FhTeamID, + &i.IsGroup, + &i.ToImport, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listExtUsers = `-- name: ListExtUsers :many +SELECT id, name, email, fh_user_id FROM ext_users +` + +func (q *Queries) ListExtUsers(ctx context.Context) ([]ExtUser, error) { + rows, err := q.db.QueryContext(ctx, listExtUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ExtUser + for rows.Next() { + var i ExtUser + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.FhUserID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listFhMembersByExtScheduleID = `-- name: ListFhMembersByExtScheduleID :many SELECT fh_users.id, fh_users.name, fh_users.email FROM ext_schedule_members JOIN ext_users ON ext_users.id = ext_schedule_members.user_id @@ -656,8 +774,163 @@ func (q *Queries) ListFhUsers(ctx context.Context) ([]FhUser, error) { return items, nil } +const listGroupExtTeamMemberships = `-- name: ListGroupExtTeamMemberships :many +SELECT ext_teams.id, ext_teams.name, ext_teams.slug, ext_teams.fh_team_id, ext_teams.is_group, ext_teams.to_import, ext_users.id, ext_users.name, ext_users.email, ext_users.fh_user_id FROM ext_teams + JOIN ext_team_groups ON ext_team_groups.group_team_id = ext_teams.id + JOIN ext_teams AS member_team ON member_team.id = ext_team_groups.member_team_id + JOIN ext_memberships ON ext_memberships.team_id = member_team.id + JOIN ext_users ON ext_users.id = ext_memberships.user_id +` + +type ListGroupExtTeamMembershipsRow struct { + ExtTeam ExtTeam `json:"ext_team"` + ExtUser ExtUser `json:"ext_user"` +} + +func (q *Queries) ListGroupExtTeamMemberships(ctx context.Context) ([]ListGroupExtTeamMembershipsRow, error) { + rows, err := q.db.QueryContext(ctx, listGroupExtTeamMemberships) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListGroupExtTeamMembershipsRow + for rows.Next() { + var i ListGroupExtTeamMembershipsRow + if err := rows.Scan( + &i.ExtTeam.ID, + &i.ExtTeam.Name, + &i.ExtTeam.Slug, + &i.ExtTeam.FhTeamID, + &i.ExtTeam.IsGroup, + &i.ExtTeam.ToImport, + &i.ExtUser.ID, + &i.ExtUser.Name, + &i.ExtUser.Email, + &i.ExtUser.FhUserID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGroupExtTeams = `-- name: ListGroupExtTeams :many +SELECT id, name, slug, fh_team_id, is_group, to_import FROM ext_teams +WHERE is_group = 1 +` + +func (q *Queries) ListGroupExtTeams(ctx context.Context) ([]ExtTeam, error) { + rows, err := q.db.QueryContext(ctx, listGroupExtTeams) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ExtTeam + for rows.Next() { + var i ExtTeam + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.FhTeamID, + &i.IsGroup, + &i.ToImport, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMemberExtTeams = `-- name: ListMemberExtTeams :many +SELECT id, name, slug, fh_team_id, is_group, to_import FROM ext_teams +WHERE id IN ( + SELECT DISTINCT member_team_id FROM ext_team_groups + WHERE group_team_id = ? +) +` + +func (q *Queries) ListMemberExtTeams(ctx context.Context, groupTeamID string) ([]ExtTeam, error) { + rows, err := q.db.QueryContext(ctx, listMemberExtTeams, groupTeamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ExtTeam + for rows.Next() { + var i ExtTeam + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.FhTeamID, + &i.IsGroup, + &i.ToImport, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listNonGroupExtTeams = `-- name: ListNonGroupExtTeams :many +SELECT id, name, slug, fh_team_id, is_group, to_import FROM ext_teams +WHERE is_group = 0 +` + +func (q *Queries) ListNonGroupExtTeams(ctx context.Context) ([]ExtTeam, error) { + rows, err := q.db.QueryContext(ctx, listNonGroupExtTeams) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ExtTeam + for rows.Next() { + var i ExtTeam + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.FhTeamID, + &i.IsGroup, + &i.ToImport, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTeams = `-- name: ListTeams :many -SELECT id, name, slug, fh_team_id, fh_name, fh_slug from linked_teams +SELECT id, name, slug, fh_team_id, is_group, to_import, fh_name, fh_slug from linked_teams ` func (q *Queries) ListTeams(ctx context.Context) ([]LinkedTeam, error) { @@ -674,6 +947,8 @@ func (q *Queries) ListTeams(ctx context.Context) ([]LinkedTeam, error) { &i.Name, &i.Slug, &i.FhTeamID, + &i.IsGroup, + &i.ToImport, &i.FhName, &i.FhSlug, ); err != nil { @@ -691,7 +966,7 @@ func (q *Queries) ListTeams(ctx context.Context) ([]LinkedTeam, error) { } const listTeamsByExtScheduleID = `-- name: ListTeamsByExtScheduleID :many -SELECT linked_teams.id, linked_teams.name, linked_teams.slug, linked_teams.fh_team_id, linked_teams.fh_name, linked_teams.fh_slug FROM linked_teams +SELECT linked_teams.id, linked_teams.name, linked_teams.slug, linked_teams.fh_team_id, linked_teams.is_group, linked_teams.to_import, linked_teams.fh_name, linked_teams.fh_slug FROM linked_teams JOIN ext_schedule_teams ON linked_teams.id = ext_schedule_teams.team_id WHERE ext_schedule_teams.schedule_id = ? ` @@ -710,6 +985,44 @@ func (q *Queries) ListTeamsByExtScheduleID(ctx context.Context, scheduleID strin &i.Name, &i.Slug, &i.FhTeamID, + &i.IsGroup, + &i.ToImport, + &i.FhName, + &i.FhSlug, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTeamsToImport = `-- name: ListTeamsToImport :many +SELECT id, name, slug, fh_team_id, is_group, to_import, fh_name, fh_slug from linked_teams WHERE to_import = 1 +` + +func (q *Queries) ListTeamsToImport(ctx context.Context) ([]LinkedTeam, error) { + rows, err := q.db.QueryContext(ctx, listTeamsToImport) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LinkedTeam + for rows.Next() { + var i LinkedTeam + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.FhTeamID, + &i.IsGroup, + &i.ToImport, &i.FhName, &i.FhSlug, ); err != nil { @@ -726,6 +1039,80 @@ func (q *Queries) ListTeamsByExtScheduleID(ctx context.Context, scheduleID strin return items, nil } +const listUnmatchedExtUsers = `-- name: ListUnmatchedExtUsers :many +SELECT id, name, email, fh_user_id FROM ext_users +WHERE fh_user_id IS NULL +` + +func (q *Queries) ListUnmatchedExtUsers(ctx context.Context) ([]ExtUser, error) { + rows, err := q.db.QueryContext(ctx, listUnmatchedExtUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ExtUser + for rows.Next() { + var i ExtUser + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.FhUserID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsersJoinByEmail = `-- name: ListUsersJoinByEmail :many +SELECT ext_users.id, ext_users.name, ext_users.email, ext_users.fh_user_id, fh_users.id, fh_users.name, fh_users.email FROM ext_users + JOIN fh_users ON fh_users.email = ext_users.email +` + +type ListUsersJoinByEmailRow struct { + ExtUser ExtUser `json:"ext_user"` + FhUser FhUser `json:"fh_user"` +} + +func (q *Queries) ListUsersJoinByEmail(ctx context.Context) ([]ListUsersJoinByEmailRow, error) { + rows, err := q.db.QueryContext(ctx, listUsersJoinByEmail) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUsersJoinByEmailRow + for rows.Next() { + var i ListUsersJoinByEmailRow + if err := rows.Scan( + &i.ExtUser.ID, + &i.ExtUser.Name, + &i.ExtUser.Email, + &i.ExtUser.FhUserID, + &i.FhUser.ID, + &i.FhUser.Name, + &i.FhUser.Email, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const markAllExtEscalationPolicyToImport = `-- name: MarkAllExtEscalationPolicyToImport :exec UPDATE ext_escalation_policies SET to_import = 1 ` @@ -743,3 +1130,12 @@ func (q *Queries) MarkExtEscalationPolicyToImport(ctx context.Context, id string _, err := q.db.ExecContext(ctx, markExtEscalationPolicyToImport, id) return err } + +const markExtTeamToImport = `-- name: MarkExtTeamToImport :exec +UPDATE ext_teams SET to_import = 1 WHERE id = ? +` + +func (q *Queries) MarkExtTeamToImport(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, markExtTeamToImport, id) + return err +} diff --git a/store/schema.sql b/store/schema.sql index dbe8088..8c2bbc7 100644 --- a/store/schema.sql +++ b/store/schema.sql @@ -1,4 +1,4 @@ -PRAGMA main.auto_vacuum = 1; +PRAGMA main.auto_vacuum=1; PRAGMA foreign_keys=ON; CREATE TABLE IF NOT EXISTS fh_users ( @@ -28,7 +28,17 @@ CREATE TABLE IF NOT EXISTS ext_teams ( id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL, - fh_team_id TEXT REFERENCES fh_teams(id) + fh_team_id TEXT REFERENCES fh_teams(id), + is_group INTEGER NOT NULL DEFAULT 0, + to_import INTEGER NOT NULL DEFAULT 0 +) STRICT; + +CREATE TABLE IF NOT EXISTS ext_team_groups ( + group_team_id TEXT NOT NULL, + member_team_id TEXT NOT NULL, + PRIMARY KEY (group_team_id, member_team_id), + FOREIGN KEY (group_team_id) REFERENCES ext_teams(id) ON DELETE CASCADE, + FOREIGN KEY (member_team_id) REFERENCES ext_teams(id) ON DELETE CASCADE ) STRICT; CREATE VIEW IF NOT EXISTS linked_teams AS @@ -39,8 +49,8 @@ CREATE TABLE IF NOT EXISTS ext_memberships ( user_id TEXT NOT NULL, team_id TEXT NOT NULL, PRIMARY KEY (user_id, team_id), - FOREIGN KEY (user_id) REFERENCES ext_users(id), - FOREIGN KEY (team_id) REFERENCES ext_teams(id) + FOREIGN KEY (user_id) REFERENCES ext_users(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES ext_teams(id) ON DELETE CASCADE ) STRICT; CREATE TABLE IF NOT EXISTS ext_schedules ( @@ -91,7 +101,7 @@ CREATE TABLE IF NOT EXISTS ext_escalation_policies ( repeat_interval TEXT, handoff_target_type TEXT NOT NULL, handoff_target_id TEXT NOT NULL, - to_import INTEGER NOT NULL + to_import INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE TABLE IF NOT EXISTS ext_escalation_policy_steps ( diff --git a/store/store.go b/store/store.go index de703e6..2b712b6 100644 --- a/store/store.go +++ b/store/store.go @@ -32,7 +32,19 @@ func (s *Store) Close() error { } func WithContext(ctx context.Context) context.Context { - s := NewStore() + s := NewMemoryStore() + + pragmaCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if _, err := s.conn.ExecContext(pragmaCtx, schema); err != nil { + panic(err) + } + + return context.WithValue(ctx, queryContextKey, s) +} + +func WithContextAndDSN(ctx context.Context, dsn string) context.Context { + s := NewStore(dsn) pragmaCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() diff --git a/tfrender/testdata/TestRenderOpsgenie/TeamWithSchedules_seed.sql b/tfrender/testdata/TestRenderOpsgenie/TeamWithSchedules_seed.sql index 2806381..ae3794f 100644 --- a/tfrender/testdata/TestRenderOpsgenie/TeamWithSchedules_seed.sql +++ b/tfrender/testdata/TestRenderOpsgenie/TeamWithSchedules_seed.sql @@ -10,12 +10,12 @@ INSERT INTO ext_users VALUES('e94e17aa-418c-44f7-8e47-1eaebf6b5343','Kiran','kir INSERT INTO ext_users VALUES('d68757c4-5eec-4560-8c5b-91c463f87dd8','Jack T.','jack@example.com','6c08bff2-98f6-4ee9-8de1-12202186d084'); INSERT INTO ext_users VALUES('a13020ca-cb08-48e3-9403-bed181a22e72','Wong','wong@example.io','032a1f07-987e-4f76-8273-136e08e50baa'); -INSERT INTO ext_teams VALUES('017e4326-abdf-4cd9-8cad-5f23bd7f4753','Wong Squad','wong-squad',NULL); -INSERT INTO ext_teams VALUES('3dd6b50f-28ed-4660-b982-606bfa6c4cf2','Platform','platform',NULL); -INSERT INTO ext_teams VALUES('946bf740-0497-4d5d-b31f-23a6e55a2719','AJ Team','aj-team',NULL); -INSERT INTO ext_teams VALUES('b7acbc33-9853-4150-8a4b-10156d9408c8','Customer Success','customer-success',NULL); -INSERT INTO ext_teams VALUES('e3436ab1-7547-4a47-a02a-36fee3dc91f9','noodlebrigade','noodlebrigade',NULL); -INSERT INTO ext_teams VALUES('f5a99a73-cdfb-49a2-af34-aeb05c59d937','Christine Test Team','christine-test-team',NULL); +INSERT INTO ext_teams VALUES('017e4326-abdf-4cd9-8cad-5f23bd7f4753','Wong Squad','wong-squad',NULL,0,1); +INSERT INTO ext_teams VALUES('3dd6b50f-28ed-4660-b982-606bfa6c4cf2','Platform','platform',NULL,0,1); +INSERT INTO ext_teams VALUES('946bf740-0497-4d5d-b31f-23a6e55a2719','AJ Team','aj-team',NULL,0,1); +INSERT INTO ext_teams VALUES('b7acbc33-9853-4150-8a4b-10156d9408c8','Customer Success','customer-success',NULL,0,1); +INSERT INTO ext_teams VALUES('e3436ab1-7547-4a47-a02a-36fee3dc91f9','noodlebrigade','noodlebrigade',NULL,0,1); +INSERT INTO ext_teams VALUES('f5a99a73-cdfb-49a2-af34-aeb05c59d937','Christine Test Team','christine-test-team',NULL,0,1); INSERT INTO ext_memberships VALUES('e94e17aa-418c-44f7-8e47-1eaebf6b5343','946bf740-0497-4d5d-b31f-23a6e55a2719'); INSERT INTO ext_memberships VALUES('9253cf00-6195-4123-a9a6-f9f1e25718d8','946bf740-0497-4d5d-b31f-23a6e55a2719'); diff --git a/tfrender/testdata/TestRenderPagerDuty/EscalationPolicy_seed.sql b/tfrender/testdata/TestRenderPagerDuty/EscalationPolicy_seed.sql index d33fcf0..b2fb7b9 100644 --- a/tfrender/testdata/TestRenderPagerDuty/EscalationPolicy_seed.sql +++ b/tfrender/testdata/TestRenderPagerDuty/EscalationPolicy_seed.sql @@ -7,7 +7,7 @@ INSERT INTO ext_users VALUES('PXI6XNI','Engineering Shared Account','eng@example INSERT INTO fh_teams VALUES('f159b173-1ffd-41ac-9254-ce8ec1142267','🐴 Cowboy Coders','cowboy-coders'); -INSERT INTO ext_teams VALUES('PV9JOXL','team-rocket','team-rocket','f159b173-1ffd-41ac-9254-ce8ec1142267'); +INSERT INTO ext_teams VALUES('PV9JOXL','team-rocket','team-rocket','f159b173-1ffd-41ac-9254-ce8ec1142267',0,1); INSERT INTO ext_memberships VALUES('PRXEEQ8','PV9JOXL'); INSERT INTO ext_memberships VALUES('PXI6XNI','PV9JOXL'); diff --git a/tfrender/testdata/TestRenderPagerDuty/TeamWithSchedules_seed.sql b/tfrender/testdata/TestRenderPagerDuty/TeamWithSchedules_seed.sql index d87ad65..1aa79f9 100644 --- a/tfrender/testdata/TestRenderPagerDuty/TeamWithSchedules_seed.sql +++ b/tfrender/testdata/TestRenderPagerDuty/TeamWithSchedules_seed.sql @@ -16,8 +16,8 @@ INSERT INTO fh_teams VALUES('47016143-6547-483a-b68a-5220b21681fd','AAAA IPv6 mi INSERT INTO fh_teams VALUES('f159b173-1ffd-41ac-9254-ce8ec1142267','🐴 Cowboy Coders','cowboy-coders'); INSERT INTO fh_teams VALUES('97d539b0-47a5-44f6-81e6-b6fcd98f23ac','Dunder Mifflin Scranton','dunder-mifflin-scranton'); -INSERT INTO ext_teams VALUES('PT54U20','Jen','jen','47016143-6547-483a-b68a-5220b21681fd'); -INSERT INTO ext_teams VALUES('PD2F80U','Jack Team','jack-team','97d539b0-47a5-44f6-81e6-b6fcd98f23ac'); +INSERT INTO ext_teams VALUES('PT54U20','Jen','jen','47016143-6547-483a-b68a-5220b21681fd',0,1); +INSERT INTO ext_teams VALUES('PD2F80U','Jack Team','jack-team','97d539b0-47a5-44f6-81e6-b6fcd98f23ac',0,1); INSERT INTO ext_memberships VALUES('PXI6XNI','PT54U20'); INSERT INTO ext_memberships VALUES('P8ZZ1ZB','PT54U20'); diff --git a/tfrender/tfrender.go b/tfrender/tfrender.go index 900370b..bd3a219 100644 --- a/tfrender/tfrender.go +++ b/tfrender/tfrender.go @@ -2,6 +2,8 @@ package tfrender import ( "context" + "database/sql" + "errors" "fmt" "os" "path/filepath" @@ -289,7 +291,7 @@ func (r *TFRender) ResourceFireHydrantOnCallSchedule(ctx context.Context) error } func (r *TFRender) ResourceFireHydrantTeams(ctx context.Context) error { - extTeams, err := store.UseQueries(ctx).ListTeams(ctx) + extTeams, err := store.UseQueries(ctx).ListTeamsToImport(ctx) if err != nil { return fmt.Errorf("querying teams: %w", err) } @@ -310,25 +312,44 @@ func (r *TFRender) ResourceFireHydrantTeams(ctx context.Context) error { fhTeamBlocks[name].SetAttributeValue("name", cty.StringVal(name)) } - members, err := store.UseQueries(ctx).ListFhMembersByExtTeamID(ctx, t.ExtTeam().ID) + // For a given t in extTeams, they may be a "group team" which contains "member team" entities. + // In those cases, the "member team" is merged into the "group team" in FireHydrant. + // As such, the user members will be consolidated to the "group team". + memberTeams, err := store.UseQueries(ctx).ListMemberExtTeams(ctx, t.ID) if err != nil { - return fmt.Errorf("querying team members: %w", err) + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("querying member teams: %w", err) + } + } + if memberTeams == nil { + memberTeams = []store.ExtTeam{} + } + teamIDs := []string{t.ID} + for _, mt := range memberTeams { + teamIDs = append(teamIDs, mt.ID) } - for _, m := range members { - if importedMembership[tfSlug+m.TFSlug()] { - continue + + for _, teamID := range teamIDs { + members, err := store.UseQueries(ctx).ListFhMembersByExtTeamID(ctx, teamID) + if err != nil { + return fmt.Errorf("querying team members: %w", err) } + for _, m := range members { + if importedMembership[tfSlug+m.TFSlug()] { + continue + } - b := fhTeamBlocks[name] - b.AppendNewline() - b.AppendNewBlock("memberships", []string{}).Body(). - SetAttributeTraversal("user_id", hcl.Traversal{ - hcl.TraverseRoot{Name: "data"}, - hcl.TraverseAttr{Name: "firehydrant_user"}, - hcl.TraverseAttr{Name: m.TFSlug()}, - hcl.TraverseAttr{Name: "id"}, - }) - importedMembership[tfSlug+m.TFSlug()] = true + b := fhTeamBlocks[name] + b.AppendNewline() + b.AppendNewBlock("memberships", []string{}).Body(). + SetAttributeTraversal("user_id", hcl.Traversal{ + hcl.TraverseRoot{Name: "data"}, + hcl.TraverseAttr{Name: "firehydrant_user"}, + hcl.TraverseAttr{Name: m.TFSlug()}, + hcl.TraverseAttr{Name: "id"}, + }) + importedMembership[tfSlug+m.TFSlug()] = true + } } // If there is an existing FireHydrant team already, declare import to prevent duplication. diff --git a/tfrender/tfrender_test.go b/tfrender/tfrender_test.go index 09bedb5..201ccdb 100644 --- a/tfrender/tfrender_test.go +++ b/tfrender/tfrender_test.go @@ -103,6 +103,9 @@ func createTeams(t *testing.T, ctx context.Context, variant string, withFhTeam b }); err != nil { t.Fatal(err) } + if err := store.UseQueries(ctx).MarkExtTeamToImport(ctx, extID); err != nil { + t.Fatal(err) + } } func TestRenderDataUser(t *testing.T) {