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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::ImportResultBuilder do
4
+ let(:state_backend) { instance_double(Umgr::StateBackend, path: '/tmp/.umgr/state.json') }
5
+ let(:provider_registry) { instance_double(Umgr::ProviderRegistry) }
6
+ let(:provider) { instance_double(Umgr::Providers::EchoProvider) }
7
+ let(:desired_state) do
8
+ {
9
+ version: 1,
10
+ resources: [
11
+ { provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'platform' } }
12
+ ]
13
+ }
14
+ end
15
+ let(:options) { { config: '/tmp/users.yml', desired_state: desired_state } }
16
+
17
+ it 'imports provider current state and persists deduplicated resources' do
18
+ allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
19
+ allow(provider).to receive(:current).and_return(
20
+ ok: true,
21
+ imported_accounts: [
22
+ { provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'infra' } },
23
+ { provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'platform' } },
24
+ { provider: 'echo', type: 'user', name: 'bob' }
25
+ ]
26
+ )
27
+ allow(state_backend).to receive(:write)
28
+
29
+ result = described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
30
+
31
+ expect(result[:ok]).to be(true)
32
+ expect(result[:status]).to eq('imported')
33
+ expect(result[:imported_count]).to eq(2)
34
+ expect(state_backend).to have_received(:write).with(
35
+ {
36
+ version: 1,
37
+ resources: [
38
+ {
39
+ provider: 'echo',
40
+ type: 'user',
41
+ name: 'alice',
42
+ attributes: { team: 'platform' },
43
+ identity: 'echo.user.alice'
44
+ },
45
+ {
46
+ provider: 'echo',
47
+ type: 'user',
48
+ name: 'bob',
49
+ identity: 'echo.user.bob'
50
+ }
51
+ ]
52
+ }
53
+ )
54
+ end
55
+
56
+ it 'uses returned resource payload when provider returns a single resource' do
57
+ allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
58
+ allow(provider).to receive(:current).and_return(
59
+ ok: true,
60
+ resource: { provider: 'echo', type: 'user', name: 'carla', attributes: { title: 'manager' } }
61
+ )
62
+ allow(state_backend).to receive(:write)
63
+
64
+ result = described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
65
+ imported = result.fetch(:state).fetch(:resources)
66
+
67
+ expect(imported).to eq(
68
+ [{ provider: 'echo', type: 'user', name: 'carla', attributes: { title: 'manager' }, identity: 'echo.user.carla' }]
69
+ )
70
+ end
71
+
72
+ it 'raises internal error when provider current returns ok: false' do
73
+ allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
74
+ allow(provider).to receive(:current).and_return(ok: false, error: 'denied')
75
+ allow(state_backend).to receive(:write)
76
+
77
+ expect do
78
+ described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
79
+ end.to raise_error(Umgr::Errors::InternalError, /denied/)
80
+ expect(state_backend).not_to have_received(:write)
81
+ end
82
+
83
+ it 'uses account fallback shape when provider returns account attributes hash' do
84
+ allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
85
+ allow(provider).to receive(:current).and_return(
86
+ ok: true,
87
+ account: { team: 'security', title: 'engineer' }
88
+ )
89
+ allow(state_backend).to receive(:write)
90
+
91
+ result = described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
92
+ resource = result.fetch(:state).fetch(:resources).first
93
+
94
+ expect(resource).to eq(
95
+ provider: 'echo',
96
+ type: 'user',
97
+ name: 'alice',
98
+ attributes: { team: 'security', title: 'engineer' },
99
+ identity: 'echo.user.alice'
100
+ )
101
+ end
102
+
103
+ it 'raises internal error when provider current returns no recognized resource keys' do
104
+ allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
105
+ allow(provider).to receive(:current).and_return(ok: true, ignored: 'value')
106
+ allow(state_backend).to receive(:write)
107
+
108
+ expect do
109
+ described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
110
+ end.to raise_error(Umgr::Errors::InternalError, /missing imported resources/)
111
+ expect(state_backend).not_to have_received(:write)
112
+ end
113
+
114
+ it 'raises internal error when account fallback is not a hash' do
115
+ allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
116
+ allow(provider).to receive(:current).and_return(ok: true, account: 'some_string')
117
+ allow(state_backend).to receive(:write)
118
+
119
+ expect do
120
+ described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
121
+ end.to raise_error(Umgr::Errors::InternalError, /missing imported resources/)
122
+ expect(state_backend).not_to have_received(:write)
123
+ end
124
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::PlanResultBuilder do
4
+ let(:state_backend) { instance_double(Umgr::StateBackend, path: '/tmp/.umgr/state.json') }
5
+ let(:desired_state) do
6
+ {
7
+ version: 1,
8
+ resources: [
9
+ { provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice' }
10
+ ]
11
+ }
12
+ end
13
+ let(:options) { { config: '/tmp/users.yml', desired_state: desired_state } }
14
+
15
+ it 'uses persisted state when available' do
16
+ allow(state_backend).to receive(:read).and_return(
17
+ version: 1,
18
+ resources: [{ provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice' }]
19
+ )
20
+
21
+ result = described_class.call(state_backend: state_backend, options: options)
22
+
23
+ expect(result[:status]).to eq('planned')
24
+ expect(result[:current_state][:resources].size).to eq(1)
25
+ expect(result[:changeset][:summary]).to eq(create: 0, update: 0, delete: 0, no_change: 1)
26
+ expect(result[:drift]).to eq(
27
+ detected: false,
28
+ change_count: 0,
29
+ actions: { create: 0, update: 0, delete: 0 }
30
+ )
31
+ end
32
+
33
+ it 'falls back to shared initial state when no persisted state exists' do
34
+ allow(state_backend).to receive(:read).and_return(nil)
35
+
36
+ result = described_class.call(state_backend: state_backend, options: options)
37
+
38
+ expect(result[:status]).to eq('planned')
39
+ expect(result[:current_state]).to eq(Umgr::StateTemplate::INITIAL_STATE)
40
+ expect(result[:changeset][:summary]).to eq(create: 1, update: 0, delete: 0, no_change: 0)
41
+ expect(result[:drift]).to eq(
42
+ detected: true,
43
+ change_count: 1,
44
+ actions: { create: 1, update: 0, delete: 0 }
45
+ )
46
+ end
47
+
48
+ it 'enriches changes with provider-specific plan details when registry is provided' do
49
+ provider_registry = instance_double(Umgr::ProviderRegistry)
50
+ provider = instance_double(Umgr::Providers::GithubProvider)
51
+ desired = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: ['admins'] }
52
+ current = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: ['platform'] }
53
+ allow(state_backend).to receive(:read).and_return(version: 1, resources: [current])
54
+ allow(provider_registry).to receive(:fetch).with('github').and_return(provider)
55
+ allow(provider).to receive(:plan).with(desired: desired, current: current).and_return(
56
+ ok: true,
57
+ provider: 'github',
58
+ status: 'planned',
59
+ operations: [{ type: 'add_team_membership', team: 'admins', login: 'alice' }]
60
+ )
61
+ custom_options = { config: '/tmp/users.yml', desired_state: { version: 1, resources: [desired] } }
62
+
63
+ result = described_class.call(
64
+ state_backend: state_backend,
65
+ options: custom_options,
66
+ provider_registry: provider_registry
67
+ )
68
+
69
+ change = result.fetch(:changeset).fetch(:changes).first
70
+ expect(change[:action]).to eq('update')
71
+ expect(change.fetch(:provider_plan)).to eq(
72
+ ok: true,
73
+ provider: 'github',
74
+ status: 'planned',
75
+ operations: [{ type: 'add_team_membership', team: 'admins', login: 'alice' }]
76
+ )
77
+ end
78
+
79
+ it 'keeps change unchanged when provider name cannot be resolved' do
80
+ provider_registry = instance_double(Umgr::ProviderRegistry)
81
+ change = { identity: 'unknown.user.alice', action: 'update', desired: nil, current: nil }
82
+
83
+ result = described_class.send(:enrich_change, change, provider_registry)
84
+
85
+ expect(result).to eq(change)
86
+ end
87
+
88
+ it 'keeps change unchanged when provider plan result is not includable' do
89
+ provider_registry = instance_double(Umgr::ProviderRegistry)
90
+ provider = instance_double(Umgr::Providers::GithubProvider)
91
+ desired = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: [] }
92
+ current = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: ['admins'] }
93
+ change = { identity: 'github.user.alice', action: 'update', desired: desired, current: current }
94
+ allow(provider_registry).to receive(:fetch).with('github').and_return(provider)
95
+ allow(provider).to receive(:plan).with(desired: desired, current: current).and_return(ok: false, status: 'error')
96
+
97
+ result = described_class.send(:enrich_change, change, provider_registry)
98
+
99
+ expect(result).to eq(change)
100
+ end
101
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::ProviderContract do
4
+ let(:provider_class) do
5
+ Class.new do
6
+ def validate(resource:); end
7
+ def current(resource:); end
8
+ def plan(desired:, current:); end
9
+ def apply(changeset:); end
10
+ end
11
+ end
12
+
13
+ it 'accepts providers implementing the contract' do
14
+ provider = provider_class.new
15
+
16
+ expect(described_class.validate!(provider)).to eq(provider)
17
+ end
18
+
19
+ it 'raises when required methods are missing' do
20
+ invalid_provider = Object.new
21
+
22
+ expect { described_class.validate!(invalid_provider) }
23
+ .to raise_error(Umgr::Errors::ProviderContractError, /must implement concrete methods/)
24
+ end
25
+
26
+ it 'raises when provider uses abstract base implementations' do
27
+ abstract_provider = Umgr::Provider.new
28
+
29
+ expect { described_class.validate!(abstract_provider) }
30
+ .to raise_error(Umgr::Errors::ProviderContractError, /must implement concrete methods/)
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::ProviderRegistry do
4
+ let(:registry) { described_class.new }
5
+ let(:provider) do
6
+ Class.new do
7
+ def validate(resource:); end
8
+ def current(resource:); end
9
+ def plan(desired:, current:); end
10
+ def apply(changeset:); end
11
+ end.new
12
+ end
13
+
14
+ it 'registers and fetches providers by normalized name' do
15
+ registry.register('github', provider)
16
+
17
+ expect(registry.fetch(:github)).to eq(provider)
18
+ expect(registry.fetch('github')).to eq(provider)
19
+ expect(registry.fetch(:echo)).to be_a(Umgr::Providers::EchoProvider)
20
+ end
21
+
22
+ it 'registers built-in github provider by default' do
23
+ expect(registry.fetch(:github)).to be_a(Umgr::Providers::GithubProvider)
24
+ end
25
+
26
+ it 'lists registered provider names' do
27
+ registry.register(:slack, provider)
28
+ registry.register('github', provider)
29
+
30
+ expect(registry.names).to eq(%i[echo github slack])
31
+ end
32
+
33
+ it 'raises for empty provider names' do
34
+ expect { registry.register(' ', provider) }
35
+ .to raise_error(Umgr::Errors::ValidationError, /Provider name must be a non-empty/)
36
+ end
37
+
38
+ it 'raises when provider contract is incomplete' do
39
+ expect { registry.register('github', Object.new) }
40
+ .to raise_error(Umgr::Errors::ProviderContractError, /must implement/)
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::ProviderResourceValidator do
4
+ it 'delegates validation to provider for each desired resource' do
5
+ provider = instance_double(Umgr::Providers::EchoProvider)
6
+ registry = instance_double(Umgr::ProviderRegistry)
7
+ desired_state = {
8
+ resources: [
9
+ { provider: 'echo', type: 'user', name: 'alice' },
10
+ { provider: 'echo', type: 'user', name: 'bob' }
11
+ ]
12
+ }
13
+
14
+ allow(registry).to receive(:fetch).with('echo').and_return(provider)
15
+ allow(provider).to receive(:validate)
16
+
17
+ described_class.validate!(desired_state: desired_state, provider_registry: registry)
18
+
19
+ expect(provider).to have_received(:validate).with(resource: { provider: 'echo', type: 'user', name: 'alice' })
20
+ expect(provider).to have_received(:validate).with(resource: { provider: 'echo', type: 'user', name: 'bob' })
21
+ end
22
+
23
+ it 'raises when provider returns ok: false validation result' do
24
+ provider = instance_double(Umgr::Providers::EchoProvider)
25
+ registry = instance_double(Umgr::ProviderRegistry)
26
+ desired_state = {
27
+ resources: [
28
+ { provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice' }
29
+ ]
30
+ }
31
+
32
+ allow(registry).to receive(:fetch).with('echo').and_return(provider)
33
+ allow(provider).to receive(:validate).and_return(ok: false, error: 'provider validation failed')
34
+
35
+ expect do
36
+ described_class.validate!(desired_state: desired_state, provider_registry: registry)
37
+ end.to raise_error(Umgr::Errors::ValidationError, /provider validation failed for resource echo\.user\.alice/)
38
+ end
39
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::Provider do
4
+ subject(:provider) { described_class.new }
5
+
6
+ it 'raises for validate by default' do
7
+ expect { provider.validate(resource: {}) }
8
+ .to raise_error(Umgr::Errors::AbstractMethodError, /must implement #validate/)
9
+ end
10
+
11
+ it 'raises for current by default' do
12
+ expect { provider.current(resource: {}) }
13
+ .to raise_error(Umgr::Errors::AbstractMethodError, /must implement #current/)
14
+ end
15
+
16
+ it 'raises for plan by default' do
17
+ expect { provider.plan(desired: {}, current: {}) }
18
+ .to raise_error(Umgr::Errors::AbstractMethodError, /must implement #plan/)
19
+ end
20
+
21
+ it 'raises for apply by default' do
22
+ expect { provider.apply(changeset: {}) }
23
+ .to raise_error(Umgr::Errors::AbstractMethodError, /must implement #apply/)
24
+ end
25
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::Providers::EchoProvider do
4
+ subject(:provider) { described_class.new }
5
+
6
+ let(:resource) do
7
+ {
8
+ provider: 'echo',
9
+ type: 'user',
10
+ name: 'alice',
11
+ attributes: {
12
+ email: 'alice@example.com',
13
+ team: 'platform'
14
+ }
15
+ }
16
+ end
17
+
18
+ it 'echoes resource in validate' do
19
+ result = provider.validate(resource: resource)
20
+
21
+ expect(result[:ok]).to be(true)
22
+ expect(result[:provider]).to eq('echo')
23
+ expect(result[:resource]).to eq(resource)
24
+ end
25
+
26
+ it 'echoes account attributes in current' do
27
+ result = provider.current(resource: resource)
28
+
29
+ expect(result[:ok]).to be(true)
30
+ expect(result[:account]).to eq(email: 'alice@example.com', team: 'platform')
31
+ end
32
+
33
+ it 'returns update status when desired and current differ' do
34
+ result = provider.plan(desired: { email: 'alice@example.com' }, current: { email: 'old@example.com' })
35
+
36
+ expect(result[:ok]).to be(true)
37
+ expect(result[:status]).to eq('update')
38
+ end
39
+
40
+ it 'returns no_change status when desired and current are the same' do
41
+ desired = { email: 'alice@example.com' }
42
+ result = provider.plan(desired: desired, current: desired)
43
+
44
+ expect(result[:ok]).to be(true)
45
+ expect(result[:status]).to eq('no_change')
46
+ end
47
+
48
+ it 'echoes changeset in apply' do
49
+ result = provider.apply(changeset: { action: 'update' })
50
+
51
+ expect(result[:ok]).to be(true)
52
+ expect(result[:status]).to eq('applied')
53
+ expect(result[:changeset]).to eq(action: 'update')
54
+ end
55
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::Providers::GithubAccountNormalizer do
4
+ it 'normalizes github user payload into canonical account resource shape' do
5
+ user = {
6
+ 'id' => 10,
7
+ 'login' => 'alice',
8
+ 'avatar_url' => 'https://avatars.example/alice',
9
+ 'html_url' => 'https://github.com/alice',
10
+ 'type' => 'User'
11
+ }
12
+
13
+ result = described_class.call(user: user, org: 'acme', teams: %w[admins platform])
14
+
15
+ expect(result).to eq(
16
+ provider: 'github',
17
+ type: 'user',
18
+ name: 'alice',
19
+ identity: 'github.user.alice',
20
+ org: 'acme',
21
+ teams: %w[admins platform],
22
+ attributes: {
23
+ id: 10,
24
+ login: 'alice',
25
+ avatar_url: 'https://avatars.example/alice',
26
+ html_url: 'https://github.com/alice',
27
+ type: 'User'
28
+ }
29
+ )
30
+ end
31
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ RSpec.describe Umgr::Providers::GithubApiClient do
6
+ subject(:client) { described_class.new }
7
+
8
+ it 'paginates org users with link headers' do
9
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/members(\?.*)?\z}).to_return(
10
+ status: 200,
11
+ body: JSON.generate([{ login: 'alice' }]),
12
+ headers: {
13
+ 'Content-Type' => 'application/json',
14
+ 'Link' => '<https://api.github.com/orgs/acme/members?page=2>; rel="next"'
15
+ }
16
+ )
17
+ stub_request(:get, 'https://api.github.com/orgs/acme/members?page=2').to_return(
18
+ status: 200,
19
+ body: JSON.generate([{ login: 'bob' }]),
20
+ headers: { 'Content-Type' => 'application/json' }
21
+ )
22
+
23
+ result = client.list_org_users(org: 'acme', token: 'secret')
24
+
25
+ expect(result.map { |user| user[:login] || user['login'] }).to eq(%w[alice bob])
26
+ end
27
+
28
+ it 'builds team memberships map from org teams and members endpoints' do
29
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/teams(\?.*)?\z}).to_return(
30
+ status: 200,
31
+ body: JSON.generate([{ id: 101, slug: 'platform' }, { id: 102, slug: 'admins' }]),
32
+ headers: { 'Content-Type' => 'application/json' }
33
+ )
34
+ stub_request(:get, %r{\Ahttps://api\.github\.com/teams/101/members(\?.*)?\z}).to_return(
35
+ status: 200,
36
+ body: JSON.generate([{ login: 'alice' }, { login: 'bob' }]),
37
+ headers: { 'Content-Type' => 'application/json' }
38
+ )
39
+ stub_request(:get, %r{\Ahttps://api\.github\.com/teams/102/members(\?.*)?\z}).to_return(
40
+ status: 200,
41
+ body: JSON.generate([{ login: 'alice' }]),
42
+ headers: { 'Content-Type' => 'application/json' }
43
+ )
44
+
45
+ result = client.list_org_team_memberships(org: 'acme', token: 'secret')
46
+
47
+ expect(result).to eq(
48
+ 'alice' => %w[admins platform],
49
+ 'bob' => ['platform']
50
+ )
51
+ end
52
+
53
+ it 'raises api error on non-success responses' do
54
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/members(\?.*)?\z}).to_return(
55
+ status: 500,
56
+ body: JSON.generate(message: 'internal error'),
57
+ headers: { 'Content-Type' => 'application/json' }
58
+ )
59
+
60
+ expect { client.list_org_users(org: 'acme', token: 'secret') }
61
+ .to raise_error(Umgr::Errors::ApiError, /GitHub API request failed/)
62
+ end
63
+
64
+ it 'invites user to organization membership' do
65
+ stub_request(:put, 'https://api.github.com/orgs/acme/memberships/alice')
66
+ .with(body: hash_including(role: 'member'))
67
+ .to_return(status: 200, body: JSON.generate(state: 'pending'), headers: { 'Content-Type' => 'application/json' })
68
+
69
+ result = client.invite_org_member(org: 'acme', login: 'alice', token: 'secret')
70
+
71
+ expect(result[:state] || result['state']).to eq('pending')
72
+ end
73
+
74
+ it 'adds and removes team membership by slug' do
75
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/teams(\?.*)?\z}).to_return(
76
+ status: 200,
77
+ body: JSON.generate([{ id: 101, slug: 'platform' }]),
78
+ headers: { 'Content-Type' => 'application/json' }
79
+ )
80
+ stub_request(:put, 'https://api.github.com/teams/101/memberships/alice').to_return(
81
+ status: 200,
82
+ body: JSON.generate(state: 'active'),
83
+ headers: { 'Content-Type' => 'application/json' }
84
+ )
85
+ stub_request(:delete, 'https://api.github.com/teams/101/memberships/alice').to_return(status: 204, body: '')
86
+
87
+ add_result = client.add_team_membership(org: 'acme', team_slug: 'platform', login: 'alice', token: 'secret')
88
+ remove_result = client.remove_team_membership(org: 'acme', team_slug: 'platform', login: 'alice', token: 'secret')
89
+
90
+ expect(add_result[:state] || add_result['state']).to eq('active')
91
+ expect(remove_result).to be(true)
92
+ end
93
+
94
+ it 'removes organization membership' do
95
+ stub_request(:delete, 'https://api.github.com/orgs/acme/memberships/alice').to_return(status: 204, body: '')
96
+
97
+ result = client.remove_org_member(org: 'acme', login: 'alice', token: 'secret')
98
+
99
+ expect(result).to be(true)
100
+ end
101
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::Providers::GithubPlanBuilder do
4
+ describe '.call' do
5
+ it 'plans invite and team membership additions for new users' do
6
+ result = described_class.call(
7
+ desired: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] },
8
+ current: nil
9
+ )
10
+
11
+ expect(result).to include(
12
+ ok: true,
13
+ provider: 'github',
14
+ status: 'planned',
15
+ organization_action: 'invite'
16
+ )
17
+ expect(result.fetch(:operations)).to eq(
18
+ [
19
+ { type: 'invite_org_member', login: 'alice' },
20
+ { type: 'add_team_membership', login: 'alice', team: 'admins' },
21
+ { type: 'add_team_membership', login: 'alice', team: 'platform' }
22
+ ]
23
+ )
24
+ end
25
+
26
+ it 'plans no_change operation when memberships are identical' do
27
+ result = described_class.call(
28
+ desired: { provider: 'github', type: 'user', name: 'alice', teams: %w[platform admins] },
29
+ current: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] }
30
+ )
31
+
32
+ expect(result.fetch(:organization_action)).to eq('keep')
33
+ expect(result.fetch(:team_actions)).to eq(add: [], remove: [], unchanged: %w[admins platform])
34
+ expect(result.fetch(:operations)).to eq([{ type: 'no_change', login: 'alice' }])
35
+ end
36
+
37
+ it 'plans org removal when user is not desired anymore' do
38
+ result = described_class.call(
39
+ desired: nil,
40
+ current: { provider: 'github', type: 'user', name: 'alice', teams: ['admins'] }
41
+ )
42
+
43
+ expect(result.fetch(:organization_action)).to eq('remove')
44
+ expect(result.fetch(:operations)).to eq([{ type: 'remove_org_member', login: 'alice' }])
45
+ end
46
+ end
47
+ end