umgr 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +14 -0
  3. data/README.md +126 -0
  4. data/Rakefile +3 -0
  5. data/exe/umgr +6 -0
  6. data/lib/umgr/apply_result_builder.rb +149 -0
  7. data/lib/umgr/change_set_builder.rb +49 -0
  8. data/lib/umgr/cli.rb +117 -0
  9. data/lib/umgr/config_validator.rb +93 -0
  10. data/lib/umgr/deep_symbolizer.rb +25 -0
  11. data/lib/umgr/desired_state_enricher.rb +15 -0
  12. data/lib/umgr/drift_report_builder.rb +19 -0
  13. data/lib/umgr/errors.rb +36 -0
  14. data/lib/umgr/import_result_builder.rb +79 -0
  15. data/lib/umgr/plan_result_builder.rb +72 -0
  16. data/lib/umgr/provider.rb +26 -0
  17. data/lib/umgr/provider_contract.rb +19 -0
  18. data/lib/umgr/provider_registry.rb +36 -0
  19. data/lib/umgr/provider_resource_validator.rb +27 -0
  20. data/lib/umgr/providers/echo_provider.rb +44 -0
  21. data/lib/umgr/providers/github/account_normalizer.rb +45 -0
  22. data/lib/umgr/providers/github/api_client.rb +111 -0
  23. data/lib/umgr/providers/github/apply_executor.rb +90 -0
  24. data/lib/umgr/providers/github/plan_builder.rb +91 -0
  25. data/lib/umgr/providers/github_provider.rb +125 -0
  26. data/lib/umgr/resource_identity.rb +11 -0
  27. data/lib/umgr/runner.rb +97 -0
  28. data/lib/umgr/runner_config.rb +52 -0
  29. data/lib/umgr/state_backend.rb +59 -0
  30. data/lib/umgr/state_template.rb +7 -0
  31. data/lib/umgr/unknown_provider_guard.rb +18 -0
  32. data/lib/umgr/version.rb +5 -0
  33. data/lib/umgr.rb +28 -0
  34. data/spec/apply_result_builder_spec.rb +142 -0
  35. data/spec/change_set_builder_spec.rb +28 -0
  36. data/spec/cli/commands_spec.rb +519 -0
  37. data/spec/cli/help_spec.rb +14 -0
  38. data/spec/cli/spec_helper.rb +13 -0
  39. data/spec/desired_state_enricher_spec.rb +30 -0
  40. data/spec/drift_report_builder_spec.rb +31 -0
  41. data/spec/import_result_builder_spec.rb +124 -0
  42. data/spec/plan_result_builder_spec.rb +101 -0
  43. data/spec/provider_contract_spec.rb +32 -0
  44. data/spec/provider_registry_spec.rb +42 -0
  45. data/spec/provider_resource_validator_spec.rb +39 -0
  46. data/spec/provider_spec.rb +25 -0
  47. data/spec/providers/echo_provider_spec.rb +55 -0
  48. data/spec/providers/github_account_normalizer_spec.rb +31 -0
  49. data/spec/providers/github_api_client_spec.rb +101 -0
  50. data/spec/providers/github_plan_builder_spec.rb +47 -0
  51. data/spec/providers/github_provider_spec.rb +268 -0
  52. data/spec/resource_identity_spec.rb +13 -0
  53. data/spec/runner_spec.rb +552 -0
  54. data/spec/spec_helper.rb +12 -0
  55. data/spec/state_backend_spec.rb +52 -0
  56. data/spec/support/simple_cov.rb +9 -0
  57. data/spec/support/webmock.rb +5 -0
  58. data/umgr.gemspec +34 -0
  59. metadata +141 -0
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module ImportResultBuilder
5
+ module_function
6
+
7
+ def call(state_backend:, options:, provider_registry:)
8
+ desired_state = options.fetch(:desired_state)
9
+ imported_resources = import_resources(
10
+ desired_state.fetch(:resources, []),
11
+ provider_registry
12
+ )
13
+ final_state = build_final_state(desired_state.fetch(:version), imported_resources)
14
+ state_backend.write(final_state)
15
+ build_result(options, state_backend, final_state)
16
+ end
17
+
18
+ def import_resources(resources, provider_registry)
19
+ resources.flat_map do |resource|
20
+ import_resource(resource, provider_registry)
21
+ end
22
+ end
23
+
24
+ def import_resource(resource, provider_registry)
25
+ provider_name = resource.fetch(:provider)
26
+ provider = provider_registry.fetch(provider_name)
27
+ current_result = provider.current(resource: resource)
28
+ ensure_successful_current!(current_result, provider_name)
29
+ extract_resources(current_result, resource)
30
+ end
31
+
32
+ def ensure_successful_current!(result, provider_name)
33
+ return unless result.fetch(:ok, result.fetch('ok', true)) == false
34
+
35
+ message = result[:error] || result['error'] || "provider current failed for #{provider_name}"
36
+ raise Errors::InternalError, message
37
+ end
38
+
39
+ def extract_resources(result, fallback_resource)
40
+ imported_accounts = result[:imported_accounts] || result['imported_accounts']
41
+ return imported_accounts if imported_accounts.is_a?(Array)
42
+
43
+ resource = result[:resource] || result['resource']
44
+ return [resource] if resource
45
+
46
+ account = result[:account] || result['account']
47
+ return [fallback_resource.merge(attributes: account)] if account.is_a?(Hash)
48
+
49
+ raise Errors::InternalError, 'Provider current result missing imported resources'
50
+ end
51
+
52
+ def build_final_state(version, imported_resources)
53
+ enriched = DesiredStateEnricher.call(version: version, resources: imported_resources)
54
+ deduped_resources = deduplicate_by_identity(enriched.fetch(:resources))
55
+ {
56
+ version: version,
57
+ resources: deduped_resources
58
+ }
59
+ end
60
+
61
+ def deduplicate_by_identity(resources)
62
+ unique = {}
63
+ resources.each { |resource| unique[resource.fetch(:identity)] = resource }
64
+ unique.values.sort_by { |resource| resource.fetch(:identity) }
65
+ end
66
+
67
+ def build_result(options, state_backend, final_state)
68
+ {
69
+ ok: true,
70
+ action: 'import',
71
+ status: 'imported',
72
+ options: options,
73
+ state_path: state_backend.path,
74
+ state: final_state,
75
+ imported_count: final_state.fetch(:resources).length
76
+ }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module PlanResultBuilder
5
+ module_function
6
+
7
+ def call(state_backend:, options:, provider_registry: nil)
8
+ desired_state = options.fetch(:desired_state)
9
+ current_state = DesiredStateEnricher.call(state_backend.read || StateTemplate::INITIAL_STATE)
10
+ changeset = build_changeset(
11
+ desired_state: desired_state,
12
+ current_state: current_state,
13
+ provider_registry: provider_registry
14
+ )
15
+ build_result(options: options, state_backend: state_backend, current_state: current_state, changeset: changeset)
16
+ end
17
+
18
+ def build_changeset(desired_state:, current_state:, provider_registry:)
19
+ changeset = ChangeSetBuilder.call(
20
+ desired_resources: desired_state.fetch(:resources, []),
21
+ current_resources: current_state.fetch(:resources, [])
22
+ )
23
+ return changeset unless provider_registry
24
+
25
+ changeset.merge(changes: enrich_changes(changeset.fetch(:changes), provider_registry))
26
+ end
27
+
28
+ def enrich_changes(changes, provider_registry)
29
+ changes.map { |change| enrich_change(change, provider_registry) }
30
+ end
31
+
32
+ def enrich_change(change, provider_registry)
33
+ provider_name = provider_name_for(change)
34
+ return change unless provider_name
35
+
36
+ provider = provider_registry.fetch(provider_name)
37
+ provider_result = provider.plan(desired: change[:desired], current: change[:current])
38
+ return change unless include_provider_plan?(provider_result)
39
+
40
+ change.merge(provider_plan: compact_provider_plan(provider_result))
41
+ end
42
+
43
+ def provider_name_for(change)
44
+ resource = change[:desired] || change[:current]
45
+ return nil unless resource
46
+
47
+ resource[:provider] || resource['provider']
48
+ end
49
+
50
+ def include_provider_plan?(provider_result)
51
+ return false unless provider_result.is_a?(Hash)
52
+
53
+ provider_result.fetch(:ok, provider_result.fetch('ok', nil)) != false
54
+ end
55
+
56
+ def compact_provider_plan(provider_result)
57
+ compacted = provider_result.dup
58
+ %i[desired current].each { |key| compacted.delete(key) }
59
+ %w[desired current].each { |key| compacted.delete(key) }
60
+ compacted
61
+ end
62
+
63
+ def build_result(options:, state_backend:, current_state:, changeset:)
64
+ result = { ok: true, action: 'plan', status: 'planned', options: options, state_path: state_backend.path }
65
+ result.merge(
66
+ current_state: current_state,
67
+ changeset: changeset,
68
+ drift: DriftReportBuilder.call(changeset.fetch(:summary))
69
+ )
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ class Provider
5
+ def validate(resource:)
6
+ _ = resource
7
+ raise Errors::AbstractMethodError, "#{self.class} must implement #validate(resource:)"
8
+ end
9
+
10
+ def current(resource:)
11
+ _ = resource
12
+ raise Errors::AbstractMethodError, "#{self.class} must implement #current(resource:)"
13
+ end
14
+
15
+ def plan(desired:, current:)
16
+ _ = desired
17
+ _ = current
18
+ raise Errors::AbstractMethodError, "#{self.class} must implement #plan(desired:, current:)"
19
+ end
20
+
21
+ def apply(changeset:)
22
+ _ = changeset
23
+ raise Errors::AbstractMethodError, "#{self.class} must implement #apply(changeset:)"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module ProviderContract
5
+ METHODS = %i[validate current plan apply].freeze
6
+
7
+ module_function
8
+
9
+ def validate!(provider)
10
+ invalid_methods = METHODS.reject do |method_name|
11
+ provider.respond_to?(method_name) && provider.method(method_name).owner != Provider
12
+ end
13
+ return provider if invalid_methods.empty?
14
+
15
+ raise Errors::ProviderContractError,
16
+ "Provider #{provider.class} must implement concrete methods: #{invalid_methods.join(', ')}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ class ProviderRegistry
5
+ def initialize
6
+ @providers = {}
7
+ register(:echo, Providers::EchoProvider.new)
8
+ register(:github, Providers::GithubProvider.new)
9
+ end
10
+
11
+ def register(name, provider)
12
+ normalized_name = normalize_name(name)
13
+ ProviderContract.validate!(provider)
14
+ providers[normalized_name] = provider
15
+ end
16
+
17
+ def fetch(name)
18
+ providers[normalize_name(name)]
19
+ end
20
+
21
+ def names
22
+ providers.keys.sort
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :providers
28
+
29
+ def normalize_name(name)
30
+ normalized = name.to_s.strip
31
+ raise Errors::ValidationError, 'Provider name must be a non-empty string or symbol' if normalized.empty?
32
+
33
+ normalized.to_sym
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module ProviderResourceValidator
5
+ module_function
6
+
7
+ def validate!(desired_state:, provider_registry:)
8
+ desired_state.fetch(:resources, []).each do |resource|
9
+ provider = provider_registry.fetch(resource[:provider])
10
+ result = provider.validate(resource: resource)
11
+ raise_validation_error!(provider: provider, resource: resource, result: result) if invalid_result?(result)
12
+ end
13
+ end
14
+
15
+ def invalid_result?(result)
16
+ return false unless result.is_a?(Hash)
17
+
18
+ result[:ok] == false || result['ok'] == false
19
+ end
20
+
21
+ def raise_validation_error!(provider:, resource:, result:)
22
+ message = result[:error] || result['error'] || "Provider #{provider.class} validation returned `ok: false`"
23
+ identity = resource[:identity] || ResourceIdentity.call(resource)
24
+ raise Errors::ValidationError, "#{message} for resource #{identity}"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module Providers
5
+ class EchoProvider < Provider
6
+ def validate(resource:)
7
+ {
8
+ ok: true,
9
+ provider: 'echo',
10
+ resource: resource
11
+ }
12
+ end
13
+
14
+ def current(resource:)
15
+ {
16
+ ok: true,
17
+ provider: 'echo',
18
+ account: resource.fetch(:attributes, {}),
19
+ resource: resource
20
+ }
21
+ end
22
+
23
+ def plan(desired:, current:)
24
+ status = desired == current ? 'no_change' : 'update'
25
+ {
26
+ ok: true,
27
+ provider: 'echo',
28
+ status: status,
29
+ desired: desired,
30
+ current: current
31
+ }
32
+ end
33
+
34
+ def apply(changeset:)
35
+ {
36
+ ok: true,
37
+ provider: 'echo',
38
+ status: 'applied',
39
+ changeset: changeset
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module Providers
5
+ module GithubAccountNormalizer
6
+ module_function
7
+
8
+ def call(user:, org:, teams:)
9
+ login = fetch_value(user, :login)
10
+ resource = base_resource(login: login)
11
+ resource.merge(
12
+ identity: ResourceIdentity.call(resource),
13
+ org: org,
14
+ teams: teams.sort,
15
+ attributes: normalized_attributes(user)
16
+ )
17
+ end
18
+
19
+ def normalized_attributes(user)
20
+ {
21
+ id: fetch_value(user, :id),
22
+ login: fetch_value(user, :login),
23
+ avatar_url: fetch_value(user, :avatar_url),
24
+ html_url: fetch_value(user, :html_url),
25
+ type: fetch_value(user, :type)
26
+ }.compact
27
+ end
28
+
29
+ def base_resource(login:)
30
+ {
31
+ provider: 'github',
32
+ type: 'user',
33
+ name: login
34
+ }
35
+ end
36
+
37
+ def fetch_value(payload, key)
38
+ value = payload[key] if payload.respond_to?(:[])
39
+ value ||= payload[key.to_s] if payload.respond_to?(:[])
40
+ value ||= payload.public_send(key) if payload.respond_to?(key)
41
+ value
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'octokit'
4
+
5
+ module Umgr
6
+ module Providers
7
+ class GithubApiClient
8
+ def list_org_users(org:, token:)
9
+ with_api_error_handling do
10
+ build_client(token: token).org_members(org)
11
+ end
12
+ end
13
+
14
+ def list_org_team_memberships(org:, token:)
15
+ with_api_error_handling do
16
+ client = build_client(token: token)
17
+ teams = client.org_teams(org)
18
+ build_team_memberships(client: client, teams: teams)
19
+ end
20
+ end
21
+
22
+ def invite_org_member(org:, login:, token:)
23
+ with_api_error_handling do
24
+ build_client(token: token).update_organization_membership(org, user: login, role: 'member')
25
+ end
26
+ end
27
+
28
+ def remove_org_member(org:, login:, token:)
29
+ with_api_error_handling do
30
+ build_client(token: token).remove_organization_membership(org, user: login)
31
+ end
32
+ end
33
+
34
+ def add_team_membership(org:, team_slug:, login:, token:)
35
+ with_api_error_handling do
36
+ client = build_client(token: token)
37
+ team_id = team_id_for!(client: client, org: org, team_slug: team_slug)
38
+ client.add_team_membership(team_id, login)
39
+ end
40
+ end
41
+
42
+ def remove_team_membership(org:, team_slug:, login:, token:)
43
+ with_api_error_handling do
44
+ client = build_client(token: token)
45
+ team_id = team_id_for!(client: client, org: org, team_slug: team_slug)
46
+ client.remove_team_membership(team_id, login)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def build_client(token:)
53
+ Octokit::Client.new(access_token: token, auto_paginate: true)
54
+ end
55
+
56
+ def build_team_memberships(client:, teams:)
57
+ memberships = Hash.new { |memo, key| memo[key] = [] }
58
+
59
+ teams.each do |team|
60
+ team_id = fetch_value(team, :id)
61
+ team_slug = fetch_value(team, :slug)
62
+ next unless team_id && team_slug
63
+
64
+ add_team_members!(memberships: memberships, client: client, team_id: team_id, team_slug: team_slug)
65
+ end
66
+
67
+ memberships.transform_values!(&:sort)
68
+ end
69
+
70
+ def add_team_members!(memberships:, client:, team_id:, team_slug:)
71
+ members = client.team_members(team_id)
72
+
73
+ members.each do |member|
74
+ login = fetch_value(member, :login)
75
+ next unless login
76
+
77
+ memberships[login] << team_slug
78
+ end
79
+ end
80
+
81
+ def fetch_value(payload, key)
82
+ return normalize_value(payload.public_send(key)) if payload.respond_to?(key)
83
+ return fetch_indexed_value(payload, key) if payload.respond_to?(:[])
84
+
85
+ nil
86
+ end
87
+
88
+ def fetch_indexed_value(payload, key)
89
+ normalize_value(payload[key] || payload[key.to_s])
90
+ end
91
+
92
+ def normalize_value(value)
93
+ value.is_a?(String) ? value : value&.to_s
94
+ end
95
+
96
+ def team_id_for!(client:, org:, team_slug:)
97
+ teams = client.org_teams(org)
98
+ matched = teams.find { |team| fetch_value(team, :slug) == team_slug }
99
+ return fetch_value(matched, :id) if matched
100
+
101
+ raise Errors::ValidationError, "GitHub team not found in org #{org}: #{team_slug}"
102
+ end
103
+
104
+ def with_api_error_handling
105
+ yield
106
+ rescue Octokit::Error => e
107
+ raise Errors::ApiError, "GitHub API request failed: #{e.message}"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module Providers
5
+ module GithubApplyExecutor
6
+ module_function
7
+
8
+ def call(changeset:, api_client:, token_resolver:, present_string:, plan_resolver:)
9
+ resource = changeset[:desired] || changeset[:current] || {}
10
+ org = resource[:org] || resource['org']
11
+ raise Errors::ValidationError, 'GitHub apply requires non-empty `org`' unless present_string.call(org)
12
+
13
+ token = token_resolver.call(resource)
14
+ operations = resolve_operations(changeset, plan_resolver)
15
+ executed_operations = execute_operations(api_client, org, token, operations)
16
+ apply_result(operations, executed_operations)
17
+ end
18
+
19
+ def resolve_operations(changeset, plan_resolver)
20
+ provider_plan = changeset[:provider_plan] || changeset['provider_plan'] || {}
21
+ operations = provider_plan[:operations] || provider_plan['operations']
22
+ return operations if operations.is_a?(Array)
23
+
24
+ plan_resolver.call(changeset[:desired], changeset[:current]).fetch(:operations)
25
+ end
26
+
27
+ def execute_operations(api_client, org, token, operations)
28
+ operations.map do |operation|
29
+ execute_operation(api_client, org, token, operation)
30
+ operation
31
+ end
32
+ end
33
+
34
+ def execute_operation(api_client, org, token, operation)
35
+ dispatch_operation(api_client, org, token, normalize_operation(operation))
36
+ end
37
+
38
+ def dispatch_operation(api_client, org, token, operation)
39
+ handler = operation_handlers(api_client, org, token)[operation[:type]]
40
+ return handler.call(operation) if handler
41
+
42
+ raise Errors::InternalError, "Unsupported GitHub apply operation: #{operation[:type]}"
43
+ end
44
+
45
+ def operation_handlers(api_client, org, token)
46
+ {
47
+ 'invite_org_member' => ->(operation) { invite_operation(api_client, org, token, operation) },
48
+ 'remove_org_member' => ->(operation) { remove_org_operation(api_client, org, token, operation) },
49
+ 'add_team_membership' => ->(operation) { add_team_operation(api_client, org, token, operation) },
50
+ 'remove_team_membership' => ->(operation) { remove_team_operation(api_client, org, token, operation) },
51
+ 'no_change' => ->(_operation) {}
52
+ }
53
+ end
54
+
55
+ def invite_operation(api_client, org, token, operation)
56
+ api_client.invite_org_member(org: org, login: operation[:login], token: token)
57
+ end
58
+
59
+ def remove_org_operation(api_client, org, token, operation)
60
+ api_client.remove_org_member(org: org, login: operation[:login], token: token)
61
+ end
62
+
63
+ def add_team_operation(api_client, org, token, operation)
64
+ api_client.add_team_membership(org: org, team_slug: operation[:team], login: operation[:login], token: token)
65
+ end
66
+
67
+ def remove_team_operation(api_client, org, token, operation)
68
+ api_client.remove_team_membership(org: org, team_slug: operation[:team], login: operation[:login], token: token)
69
+ end
70
+
71
+ def normalize_operation(operation)
72
+ {
73
+ type: operation[:type] || operation['type'],
74
+ login: operation[:login] || operation['login'],
75
+ team: operation[:team] || operation['team']
76
+ }
77
+ end
78
+
79
+ def apply_result(operations, executed_operations)
80
+ {
81
+ ok: true,
82
+ provider: 'github',
83
+ status: 'applied',
84
+ operations: operations,
85
+ executed_operations: executed_operations
86
+ }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module Providers
5
+ module GithubPlanBuilder
6
+ module_function
7
+
8
+ def call(desired:, current:)
9
+ team_actions = build_team_actions(desired: desired, current: current)
10
+ login = extract_login(desired, current)
11
+ organization_action = organization_action_for(desired: desired, current: current)
12
+ operations = build_plan_operations(
13
+ organization_action: organization_action,
14
+ login: login,
15
+ team_add: team_actions[:add],
16
+ team_remove: team_actions[:remove]
17
+ )
18
+
19
+ plan_result(organization_action: organization_action, operations: operations, team_actions: team_actions)
20
+ end
21
+
22
+ def extract_teams(resource)
23
+ return [] unless resource.is_a?(Hash)
24
+
25
+ raw = resource[:teams] || resource['teams'] || []
26
+ Array(raw).map(&:to_s).reject(&:empty?).uniq.sort
27
+ end
28
+
29
+ def extract_login(desired, current)
30
+ resource = desired || current
31
+ return nil unless resource.is_a?(Hash)
32
+
33
+ login = resource[:name] || resource['name']
34
+ login&.to_s
35
+ end
36
+
37
+ def organization_action_for(desired:, current:)
38
+ return 'invite' if desired && !current
39
+ return 'remove' if current && !desired
40
+
41
+ 'keep'
42
+ end
43
+
44
+ def build_team_actions(desired:, current:)
45
+ desired_teams = extract_teams(desired)
46
+ current_teams = extract_teams(current)
47
+ {
48
+ add: desired_teams - current_teams,
49
+ remove: current_teams - desired_teams,
50
+ unchanged: desired_teams & current_teams
51
+ }
52
+ end
53
+
54
+ def plan_result(organization_action:, operations:, team_actions:)
55
+ {
56
+ ok: true,
57
+ provider: 'github',
58
+ status: 'planned',
59
+ organization_action: organization_action,
60
+ team_actions: team_actions,
61
+ operations: operations
62
+ }
63
+ end
64
+
65
+ def build_plan_operations(organization_action:, login:, team_add:, team_remove:)
66
+ case organization_action
67
+ when 'invite'
68
+ build_invite_operations(login: login, team_add: team_add)
69
+ when 'remove'
70
+ [{ type: 'remove_org_member', login: login }]
71
+ else
72
+ build_membership_operations(login: login, team_add: team_add, team_remove: team_remove)
73
+ end
74
+ end
75
+
76
+ def build_invite_operations(login:, team_add:)
77
+ operations = [{ type: 'invite_org_member', login: login }]
78
+ operations.concat(team_add.map { |team| { type: 'add_team_membership', login: login, team: team } })
79
+ operations
80
+ end
81
+
82
+ def build_membership_operations(login:, team_add:, team_remove:)
83
+ operations = []
84
+ operations.concat(team_add.map { |team| { type: 'add_team_membership', login: login, team: team } })
85
+ operations.concat(team_remove.map { |team| { type: 'remove_team_membership', login: login, team: team } })
86
+ operations = [{ type: 'no_change', login: login }] if operations.empty?
87
+ operations
88
+ end
89
+ end
90
+ end
91
+ end