space-architect 8.0.0 → 9.1.0

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +39 -464
  4. data/lib/space_architect/architect_project.rb +90 -39
  5. data/lib/space_architect/bug_report.rb +2 -1
  6. data/lib/space_architect/cli/architect.rb +189 -11
  7. data/lib/space_architect/cli/research.rb +1 -1
  8. data/lib/space_architect/cli.rb +9 -14
  9. data/lib/space_architect/loop_cost.rb +592 -0
  10. data/lib/space_architect/research/supervisor.rb +9 -2
  11. data/lib/space_architect/routing.rb +149 -0
  12. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  13. data/lib/space_architect/session_sync/cursor.rb +1 -1
  14. data/lib/space_architect/session_sync/plist.rb +1 -1
  15. data/lib/{space_core → space_architect}/version.rb +2 -2
  16. data/lib/space_architect.rb +2 -1
  17. data/skill/architect/SKILL.md +3 -2
  18. data/skill/architect-vocabulary/SKILL.md +2 -2
  19. metadata +20 -177
  20. data/exe/space +0 -13
  21. data/exe/src +0 -13
  22. data/lib/space_architect/cli/space.rb +0 -29
  23. data/lib/space_architect/cli/src.rb +0 -38
  24. data/lib/space_core/atomic_write.rb +0 -21
  25. data/lib/space_core/cli/base_command.rb +0 -19
  26. data/lib/space_core/cli/build.rb +0 -27
  27. data/lib/space_core/cli/config.rb +0 -49
  28. data/lib/space_core/cli/current.rb +0 -16
  29. data/lib/space_core/cli/help.rb +0 -195
  30. data/lib/space_core/cli/helpers.rb +0 -115
  31. data/lib/space_core/cli/init.rb +0 -29
  32. data/lib/space_core/cli/list.rb +0 -24
  33. data/lib/space_core/cli/loop_status.rb +0 -47
  34. data/lib/space_core/cli/new.rb +0 -38
  35. data/lib/space_core/cli/pack.rb +0 -29
  36. data/lib/space_core/cli/path.rb +0 -16
  37. data/lib/space_core/cli/repeatable_options.rb +0 -84
  38. data/lib/space_core/cli/repo.rb +0 -76
  39. data/lib/space_core/cli/run.rb +0 -44
  40. data/lib/space_core/cli/shell.rb +0 -125
  41. data/lib/space_core/cli/show.rb +0 -21
  42. data/lib/space_core/cli/status.rb +0 -74
  43. data/lib/space_core/cli/use.rb +0 -17
  44. data/lib/space_core/cli.rb +0 -177
  45. data/lib/space_core/commands.rb +0 -51
  46. data/lib/space_core/config.rb +0 -152
  47. data/lib/space_core/errors.rb +0 -14
  48. data/lib/space_core/git_client.rb +0 -49
  49. data/lib/space_core/mise_client.rb +0 -37
  50. data/lib/space_core/oci_builder.rb +0 -56
  51. data/lib/space_core/oci_packer.rb +0 -103
  52. data/lib/space_core/oci_runner.rb +0 -89
  53. data/lib/space_core/paths.rb +0 -58
  54. data/lib/space_core/repo_reference.rb +0 -19
  55. data/lib/space_core/repo_resolver.rb +0 -167
  56. data/lib/space_core/shell_integration.rb +0 -438
  57. data/lib/space_core/slugger.rb +0 -16
  58. data/lib/space_core/space.rb +0 -174
  59. data/lib/space_core/space_store.rb +0 -319
  60. data/lib/space_core/state.rb +0 -86
  61. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  62. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  63. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  64. data/lib/space_core/terminal.rb +0 -161
  65. data/lib/space_core/warnings.rb +0 -13
  66. data/lib/space_core/xdg.rb +0 -33
  67. data/lib/space_core.rb +0 -26
  68. data/lib/space_src/cli/clone.rb +0 -54
  69. data/lib/space_src/cli/config.rb +0 -65
  70. data/lib/space_src/cli/daemon.rb +0 -362
  71. data/lib/space_src/cli/options.rb +0 -21
  72. data/lib/space_src/cli/org.rb +0 -199
  73. data/lib/space_src/cli/repo.rb +0 -169
  74. data/lib/space_src/cli/shell.rb +0 -121
  75. data/lib/space_src/cli/status.rb +0 -75
  76. data/lib/space_src/cli/sync.rb +0 -161
  77. data/lib/space_src/cli.rb +0 -159
  78. data/lib/space_src/cloner.rb +0 -77
  79. data/lib/space_src/config/contract.rb +0 -54
  80. data/lib/space_src/config/duration.rb +0 -79
  81. data/lib/space_src/config/model.rb +0 -49
  82. data/lib/space_src/config/store.rb +0 -156
  83. data/lib/space_src/forge/client.rb +0 -31
  84. data/lib/space_src/forge/github.rb +0 -98
  85. data/lib/space_src/launchd/agent.rb +0 -195
  86. data/lib/space_src/launchd/plist.rb +0 -129
  87. data/lib/space_src/log_rotator.rb +0 -46
  88. data/lib/space_src/migration.rb +0 -43
  89. data/lib/space_src/nav.rb +0 -99
  90. data/lib/space_src/paths.rb +0 -72
  91. data/lib/space_src/scm/client.rb +0 -87
  92. data/lib/space_src/scm/git.rb +0 -232
  93. data/lib/space_src/scm/status.rb +0 -24
  94. data/lib/space_src/shell.rb +0 -90
  95. data/lib/space_src/shell_integration.rb +0 -321
  96. data/lib/space_src/state/lock.rb +0 -59
  97. data/lib/space_src/state/store.rb +0 -140
  98. data/lib/space_src/sync/engine.rb +0 -503
  99. data/lib/space_src/sync/repo_plan.rb +0 -215
  100. data/lib/space_src/sync/report.rb +0 -18
  101. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  102. data/lib/space_src/ui/json_reporter.rb +0 -39
  103. data/lib/space_src/ui/mode.rb +0 -68
  104. data/lib/space_src/ui/plain_reporter.rb +0 -53
  105. data/lib/space_src/ui/reporter.rb +0 -48
  106. data/lib/space_src/version.rb +0 -7
  107. data/lib/space_src.rb +0 -38
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async"
4
- require "dry/monads"
5
- require "space_src/shell"
6
-
7
- module Space::Src
8
- # Resolution + COW-copy boundary for `clone`. Returns Result; no
9
- # side effects on Failure. Injected shell seam defaults to ShellRunner
10
- # (wraps Shell.run in Sync{} so the Fiber-scheduler requirement is met
11
- # from a plain synchronous CLI command — same pattern as Launchd::Agent).
12
- class Cloner
13
- include Dry::Monads[:result]
14
-
15
- class ShellRunner
16
- def run(*argv)
17
- Sync { Space::Src::Shell.run(*argv) }
18
- end
19
- end
20
-
21
- def initialize(base_dir:, shell: ShellRunner.new)
22
- @base_dir = File.expand_path(base_dir)
23
- @shell = shell
24
- end
25
-
26
- # Resolve `name` against `base_dir` and copy to `into/<leaf>`.
27
- # Returns Success(dest_path) or Failure(message).
28
- def call(name:, into: ".")
29
- result = resolve(name)
30
- return result if result.failure?
31
- copy(result.success, into)
32
- end
33
-
34
- private
35
-
36
- def resolve(name)
37
- parts = name.split("/")
38
- case parts.length
39
- when 1
40
- # paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
41
- candidates = Dir.glob(File.join(@base_dir, "*", "*", name))
42
- case candidates.length
43
- when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
44
- when 1 then Success(candidates.first)
45
- else ambiguous(name, candidates, "owner/name or host/owner/name")
46
- end
47
- when 2
48
- owner, repo_name = parts
49
- # paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
50
- candidates = Dir.glob(File.join(@base_dir, "*", owner, repo_name))
51
- case candidates.length
52
- when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
53
- when 1 then Success(candidates.first)
54
- else ambiguous(name, candidates, "host/owner/name")
55
- end
56
- when 3
57
- path = File.join(@base_dir, *parts)
58
- File.directory?(path) ? Success(path) : Failure("#{name.inspect} not found under base_dir #{@base_dir}")
59
- else
60
- Failure("invalid repo reference: #{name.inspect}")
61
- end
62
- end
63
-
64
- def ambiguous(name, candidates, hint)
65
- list = candidates.map { |c| " #{c.delete_prefix("#{@base_dir}/")}" }.join("\n")
66
- Failure("ambiguous name #{name.inspect}; qualify with #{hint}:\n#{list}")
67
- end
68
-
69
- def copy(src, into)
70
- leaf = File.basename(src)
71
- dest = File.join(File.expand_path(into), leaf)
72
- return Failure("destination already exists: #{dest}") if File.exist?(dest)
73
- result = @shell.run("cp", "-Rc", src, dest)
74
- result.success? ? Success(dest) : result
75
- end
76
- end
77
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/validation"
4
- require "dry/validation/extensions/monads"
5
- require "dry/monads"
6
-
7
- module Space::Src
8
- module Config
9
- # Validates the raw YAML hash before it is built into a Config struct.
10
- # Returns a Dry::Monads::Result (via the :monads extension):
11
- # Success(validated_hash) — keys are coerced + bad keys dropped
12
- # Failure(errors_to_h) — field-level messages keyed by path
13
- #
14
- # Per gate G2, each rejection case (missing required field, bad
15
- # refresh_interval, non-integer concurrency, malformed repo entry,
16
- # malformed org entry) must produce a Failure with a field-level
17
- # message — verified by Config::ContractTest.
18
- class Contract < Dry::Validation::Contract
19
- include Dry::Monads[:result]
20
-
21
- schema do
22
- optional(:base_dir).filled(:string)
23
- optional(:refresh_interval).filled(:integer, gt?: 0)
24
- optional(:concurrency).filled(:integer, gt?: 0)
25
-
26
- optional(:repos).array(:hash) do
27
- optional(:host).filled(:string)
28
- required(:owner).filled(:string)
29
- required(:name).filled(:string)
30
- end
31
-
32
- optional(:orgs).array(:hash) do
33
- optional(:host).filled(:string)
34
- required(:name).filled(:string)
35
- optional(:include_archived).filled(:bool)
36
- optional(:include_forks).filled(:bool)
37
- optional(:ignored_repos).array(:string)
38
- end
39
- end
40
-
41
- # Override call to return a Dry::Monads::Result. Dry-validation's
42
- # monads extension gives us Result#to_monad; we map to our own
43
- # namespace so callers see a single Result type at every boundary.
44
- def call(input)
45
- result = super
46
- if result.success?
47
- Success(result.to_h)
48
- else
49
- Failure(result.errors.to_h)
50
- end
51
- end
52
- end
53
- end
54
- end
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/monads"
4
-
5
- module Space::Src
6
- module Config
7
- # CF1: parse a human-duration string into integer seconds.
8
- #
9
- # The contract and model keep `refresh_interval` as an integer
10
- # (types::Integer.constrained(gt: 0)); this module is the load-
11
- # layer normalization that lets a hand-edited config.yaml contain
12
- # "6h" / "90m" / "45s" / "30d" and have it round-trip through
13
- # Config::Store.load as 21600 / 5400 / 45 / 2592000 — so a user
14
- # who writes `refresh_interval: 6h` in their config gets the
15
- # same effect as `refresh_interval: 21600` without ever touching
16
- # the contract.
17
- #
18
- # The write-back path emits integer seconds (the human form is
19
- # not preserved on rewrite — consistent with Slice 1's documented
20
- # YAML comment-loss limitation, see test_write_emits_only_managed_fields).
21
- #
22
- # Usage:
23
- # Duration.parse("6h") # => Success(21600)
24
- # Duration.parse(21600) # => Success(21600)
25
- # Duration.parse("-3h") # => Failure("invalid duration: \"-3h\"")
26
- # Duration.parse("6x") # => Failure("invalid duration: \"6x\"")
27
- module Duration
28
- extend Dry::Monads[:result]
29
-
30
- # Unit suffixes recognized (PRD §3.1 documents "6h" / "90m" /
31
- # integer seconds; we also accept "s" and "d" for completeness
32
- # — they're natural extensions and cost zero code).
33
- UNIT_SECONDS = {
34
- nil => 1, # bare integer string ("21600") or Integer input
35
- "s" => 1,
36
- "m" => 60,
37
- "h" => 3600,
38
- "d" => 86_400
39
- }.freeze
40
-
41
- # Strictly positive integer (no sign, no decimal, no leading
42
- # zeros that change the value's magnitude). Bare integer
43
- # strings ("21600") are accepted by making the unit suffix
44
- # optional in the pattern.
45
- PATTERN = /\A(\d+)([smhd])?\z/
46
-
47
- def self.parse(value)
48
- case value
49
- when Integer
50
- return failure_for(value) if value <= 0
51
- Success(value)
52
- when String
53
- parse_string(value)
54
- else
55
- failure_for(value)
56
- end
57
- end
58
-
59
- def self.parse_string(str)
60
- s = str.strip
61
- return failure_for(str) if s.empty?
62
- m = PATTERN.match(s)
63
- return failure_for(str) unless m
64
- n = m[1].to_i
65
- unit = m[2]
66
- # The pattern guarantees n is a positive integer ("\d+" matches
67
- # 1+ digits, never "0" with no other digits… well, "0" would
68
- # match with n=0). Reject zero explicitly to keep the
69
- # contract's "gt?: 0" guarantee.
70
- return failure_for(str) if n <= 0
71
- Success(n * UNIT_SECONDS.fetch(unit))
72
- end
73
-
74
- def self.failure_for(value)
75
- Failure("invalid duration: #{value.inspect} (expected positive integer or \"<n>[s|m|h|d]\" e.g. \"6h\", \"90m\", \"21600\")")
76
- end
77
- end
78
- end
79
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/struct"
4
- require "dry/types"
5
-
6
- module Space::Src
7
- module Config
8
- Types = Dry.Types()
9
-
10
- # Default host for repo/org entries. Per PRD §3.1, host is omitted in
11
- # the YAML when the user means github.com.
12
- DEFAULT_HOST = "github.com"
13
- # Per PRD §3.1, default refresh_interval is 6h. Stored as integer seconds.
14
- DEFAULT_REFRESH_INTERVAL = 6 * 3600
15
- DEFAULT_CONCURRENCY = 8
16
-
17
- class RepoRef < Dry::Struct
18
- transform_keys(&:to_sym)
19
-
20
- attribute :host, Types::String.default(DEFAULT_HOST)
21
- attribute :owner, Types::String
22
- attribute :name, Types::String
23
- end
24
-
25
- class OrgRef < Dry::Struct
26
- transform_keys(&:to_sym)
27
-
28
- attribute :host, Types::String.default(DEFAULT_HOST)
29
- attribute :name, Types::String
30
- attribute :include_archived, Types::Bool.default(false)
31
- attribute :include_forks, Types::Bool.default(false)
32
- attribute :ignored_repos, Types::Array.of(Types::String).default([].freeze)
33
- end
34
-
35
- # Top-level config. The on-disk schema per PRD §3.1:
36
- # base_dir, refresh_interval, concurrency, repos, orgs.
37
- # `base_dir` has no struct-level default — the value is set from
38
- # Paths::DEFAULT_BASE_DIR at store-load time (see Config::Store).
39
- class Config < Dry::Struct
40
- transform_keys(&:to_sym)
41
-
42
- attribute :base_dir, Types::String
43
- attribute :refresh_interval, Types::Integer.constrained(gt: 0)
44
- attribute :concurrency, Types::Integer.constrained(gt: 0)
45
- attribute :repos, Types::Array.of(RepoRef).default([].freeze)
46
- attribute :orgs, Types::Array.of(OrgRef).default([].freeze)
47
- end
48
- end
49
- end
@@ -1,156 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "fileutils"
5
- require "dry/monads"
6
- require "space_src/config/model"
7
- require "space_src/config/contract"
8
-
9
- module Space::Src
10
- module Config
11
- # Load/validate/write-back the YAML config file.
12
- #
13
- # The store is the only place that touches the disk. It does not
14
- # preserve unknown keys or YAML comments on write — that's a known
15
- # limitation per the PRD §2 (YAML comment loss accepted) and is
16
- # documented in the Slice 1 lane report. Managed fields round-trip
17
- # byte-identically (gate G1).
18
- class Store
19
- extend Dry::Monads[:result]
20
-
21
- DEFAULT_BASE_DIR = Space::Src::Paths::DEFAULT_BASE_DIR
22
- DEFAULT_REFRESH_INTERVAL = 6 * 3600
23
- DEFAULT_CONCURRENCY = 8
24
-
25
- def self.load(path)
26
- raw = read_yaml(path)
27
- hash = symbolize(raw)
28
- # CF1: normalize `refresh_interval` from a human-duration
29
- # string ("6h", "90m", "45s", "30d") or bare integer string
30
- # ("21600") into integer seconds BEFORE the contract runs.
31
- # The contract stays integer-typed (:integer, gt?: 0); this
32
- # is a load-layer normalization that lets a hand-edited
33
- # config.yaml round-trip without rejecting "6h" as a
34
- # non-integer. See lib/space_src/config/duration.rb.
35
- if hash.key?(:refresh_interval)
36
- result = Duration.parse(hash[:refresh_interval])
37
- return result if result.failure?
38
- hash[:refresh_interval] = result.success
39
- end
40
- with_defaults(hash) do |filled|
41
- result = Contract.new.call(filled)
42
- if result.success?
43
- Success(Config.new(result.success))
44
- else
45
- Failure(result.failure)
46
- end
47
- end
48
- rescue Errno::ENOENT
49
- # Missing file is treated as an empty config (load defaults).
50
- # The store does not create the file on read — that is the
51
- # writer's job (write() is idempotent and always validates first).
52
- Success(Config.new(defaults))
53
- end
54
-
55
- def self.write(path, config)
56
- # Always re-validate the struct's contents before writing. This
57
- # guards against a caller constructing a Config with a
58
- # constraint-violating value via Struct.new (which does not run
59
- # the same checks as the contract).
60
- hash = config.to_h
61
- result = Contract.new.call(hash)
62
- return result if result.failure?
63
-
64
- FileUtils.mkdir_p(File.dirname(path))
65
- File.write(path, emit(hash))
66
- Success(config)
67
- end
68
-
69
- def self.update(path)
70
- config = load(path).success
71
- new_config = yield(config)
72
- write(path, new_config)
73
- end
74
-
75
- # dry-struct update idiom: pass a hash of attributes to
76
- # `Config#new` to get a new struct with the overrides applied
77
- # (existing fields are kept).
78
- def self.with(config, **changes)
79
- config.new(**changes)
80
- end
81
-
82
- # Hash → human-clean YAML string. String keys, defaults omitted.
83
- # Stable key order: base_dir, refresh_interval, concurrency, repos, orgs.
84
- # repos/orgs omitted when empty.
85
- def self.emit(hash)
86
- ordered = {}
87
- ordered["base_dir"] = hash[:base_dir] if hash.key?(:base_dir)
88
- ordered["refresh_interval"] = hash[:refresh_interval] if hash.key?(:refresh_interval)
89
- ordered["concurrency"] = hash[:concurrency] if hash.key?(:concurrency)
90
-
91
- repos = hash[:repos]
92
- ordered["repos"] = repos.map { |r| compact_repo(r) } if repos && !repos.empty?
93
-
94
- orgs = hash[:orgs]
95
- ordered["orgs"] = orgs.map { |o| compact_org(o) } if orgs && !orgs.empty?
96
-
97
- YAML.dump(ordered, line_width: -1)
98
- end
99
-
100
- def self.compact_repo(r)
101
- h = {}
102
- h["host"] = r[:host] if r[:host] && r[:host] != DEFAULT_HOST
103
- h["owner"] = r[:owner]
104
- h["name"] = r[:name]
105
- h
106
- end
107
-
108
- def self.compact_org(o)
109
- h = {}
110
- h["host"] = o[:host] if o[:host] && o[:host] != DEFAULT_HOST
111
- h["name"] = o[:name]
112
- h["include_archived"] = true if o[:include_archived]
113
- h["include_forks"] = true if o[:include_forks]
114
- ignored = o[:ignored_repos]
115
- h["ignored_repos"] = ignored if ignored && !ignored.empty?
116
- h
117
- end
118
-
119
- def self.read_yaml(path)
120
- return {} unless File.exist?(path)
121
- # Symbol permitted because some YAML files use :symbol keys; the
122
- # store's symbolize() then re-keys consistently anyway. We still
123
- # disallow arbitrary classes.
124
- YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false) || {}
125
- end
126
-
127
- def self.symbolize(value)
128
- case value
129
- when Hash
130
- value.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = symbolize(v) }
131
- when Array
132
- value.map { |v| symbolize(v) }
133
- else
134
- value
135
- end
136
- end
137
-
138
- # Fill in missing top-level defaults before validation, so an empty
139
- # YAML produces a fully-populated Config.
140
- def self.with_defaults(hash)
141
- filled = defaults.merge(hash)
142
- yield(filled)
143
- end
144
-
145
- def self.defaults
146
- {
147
- base_dir: DEFAULT_BASE_DIR,
148
- refresh_interval: DEFAULT_REFRESH_INTERVAL,
149
- concurrency: DEFAULT_CONCURRENCY,
150
- repos: [],
151
- orgs: []
152
- }
153
- end
154
- end
155
- end
156
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/monads"
4
- require "space_src/config/model"
5
-
6
- module Space::Src
7
- module Forge
8
- # Abstract forge interface. The GitHub implementation lists the
9
- # repos belonging to an OrgRef. The interface is intentionally
10
- # narrow: a forge is a source of (host, owner, name) triples. The
11
- # sync engine expands an OrgRef into RepoRefs at sync time; it
12
- # never asks the forge about a specific repo.
13
- class Client
14
- extend Dry::Monads[:result]
15
-
16
- # Returns Success(:authenticated) or Failure({reason:}).
17
- # Called ONCE by the engine before fanning out org listings.
18
- def check_authenticated
19
- raise NotImplementedError
20
- end
21
-
22
- # Returns Success([RepoRef, ...]) or Failure. Honors the
23
- # include_archived / include_forks flags on the OrgRef.
24
- # Does NOT perform authentication — the engine calls
25
- # check_authenticated first.
26
- def list_org(org_ref)
27
- raise NotImplementedError
28
- end
29
- end
30
- end
31
- end
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "space_src/forge/client"
5
- require "space_src/shell"
6
- require "space_src/config/model"
7
-
8
- module Space::Src
9
- module Forge
10
- # `gh repo list <org> --json …` implementation of Forge::Client.
11
- #
12
- # Per AGENTS.md gotcha, `gh` can silently fall back to
13
- # unauthenticated (60 req/hr). We probe `gh auth status` once
14
- # (via the engine calling check_authenticated before listing)
15
- # and surface a clear Failure rather than risking the rate-limit wall.
16
- class GitHub < Client
17
- # Default page size for `gh repo list`. Matches gh's own --limit
18
- # cap; orgs with >1000 repos are out of scope for Slice 1.
19
- LIST_LIMIT = 1000
20
-
21
- def initialize(shell: Shell)
22
- @shell = shell
23
- end
24
-
25
- # Probe `gh auth status`. On stderr the unauthenticated case is
26
- # obvious: "You are not logged into any GitHub hosts." We treat
27
- # that exact phrase as Failure; otherwise Success.
28
- #
29
- # Public so the engine can call it once before fanning out org
30
- # listings. `list_org` no longer authenticates per-call.
31
- def check_authenticated
32
- # `gh auth status` writes a human-friendly summary to stdout
33
- # and exits 0 when authenticated, 1 when not. We also fail
34
- # when the binary is missing (status 127 from the shell).
35
- result = @shell.run("gh", "auth", "status")
36
- return result if result.failure?
37
-
38
- if result.success.include?("not logged into any GitHub hosts")
39
- Dry::Monads::Failure({reason: "gh not authenticated; run `gh auth login` first"})
40
- else
41
- Dry::Monads::Success(:authenticated)
42
- end
43
- end
44
-
45
- def list_org(org_ref)
46
- return Dry::Monads::Failure({org: org_ref.name, reason: "missing org name"}) if org_ref.name.nil? || org_ref.name.empty?
47
-
48
- argv = build_argv(org_ref)
49
- result = @shell.run(*argv)
50
- return result if result.failure?
51
-
52
- parsed = parse_repos(result.success, org_ref)
53
- Dry::Monads::Success(parsed)
54
- rescue JSON::ParserError => e
55
- Dry::Monads::Failure({org: org_ref.name, reason: "invalid JSON from gh", error: e.message})
56
- end
57
-
58
- def build_argv(org_ref)
59
- # G11 fix (Slice 2): `--no-source` is NOT a valid `gh repo list`
60
- # flag (`gh repo list --help` lists `--archived`, `--no-archived`,
61
- # `--fork`, `--source`, `--json`, `--limit`, `--topic`,
62
- # `--language`, `--visibility`, `--jq`, `--template` — no
63
- # `--no-source`). Fork exclusion is handled authoritatively in
64
- # `parse_repos` below (the `include_forks` filter), so we no
65
- # longer emit an advisory CLI flag for it. The existing G6
66
- # behavioral tests for `include_forks=false` still pass.
67
- argv = ["gh", "repo", "list", org_ref.name, "--json", "nameWithOwner,defaultBranchRef,isArchived,isFork", "--limit", LIST_LIMIT.to_s]
68
- argv << "--no-archived" unless org_ref.include_archived
69
- argv
70
- end
71
-
72
- private
73
-
74
- # Parses gh's JSON output into RepoRef structs, honoring
75
- # include_archived / include_forks (the CLI flags are advisory;
76
- # the filter is authoritative so a future gh flag rename
77
- # doesn't break us silently).
78
- def parse_repos(json_text, org_ref)
79
- rows = JSON.parse(json_text)
80
- ignored = org_ref.ignored_repos
81
- rows.map do |row|
82
- next if !org_ref.include_archived && row["isArchived"]
83
- next if !org_ref.include_forks && row["isFork"]
84
-
85
- owner, name = row.fetch("nameWithOwner").split("/", 2)
86
- next if ignored.include?(name) || ignored.include?(row.fetch("nameWithOwner"))
87
- # default_branch is state, not config — the SCM layer resolves
88
- # it on the local clone (see SCM::Git#default_branch).
89
- Config::RepoRef.new(
90
- host: org_ref.host,
91
- owner: owner,
92
- name: name
93
- )
94
- end.compact
95
- end
96
- end
97
- end
98
- end