space-architect 1.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +284 -0
  4. data/exe/architect +13 -0
  5. data/exe/space +13 -0
  6. data/lib/space_architect/architect_mission.rb +436 -0
  7. data/lib/space_architect/atomic_write.rb +21 -0
  8. data/lib/space_architect/cli/architect.rb +388 -0
  9. data/lib/space_architect/cli/config.rb +61 -0
  10. data/lib/space_architect/cli/current.rb +22 -0
  11. data/lib/space_architect/cli/helpers.rb +117 -0
  12. data/lib/space_architect/cli/init.rb +35 -0
  13. data/lib/space_architect/cli/list.rb +30 -0
  14. data/lib/space_architect/cli/new.rb +43 -0
  15. data/lib/space_architect/cli/options.rb +12 -0
  16. data/lib/space_architect/cli/path.rb +22 -0
  17. data/lib/space_architect/cli/repo.rb +88 -0
  18. data/lib/space_architect/cli/shell.rb +137 -0
  19. data/lib/space_architect/cli/show.rb +27 -0
  20. data/lib/space_architect/cli/space.rb +35 -0
  21. data/lib/space_architect/cli/src.rb +32 -0
  22. data/lib/space_architect/cli/status.rb +39 -0
  23. data/lib/space_architect/cli/use.rb +23 -0
  24. data/lib/space_architect/cli.rb +102 -0
  25. data/lib/space_architect/config.rb +152 -0
  26. data/lib/space_architect/dispatcher.rb +21 -0
  27. data/lib/space_architect/errors.rb +14 -0
  28. data/lib/space_architect/git_client.rb +49 -0
  29. data/lib/space_architect/harness.rb +168 -0
  30. data/lib/space_architect/mise_client.rb +37 -0
  31. data/lib/space_architect/repo_reference.rb +19 -0
  32. data/lib/space_architect/repo_resolver.rb +167 -0
  33. data/lib/space_architect/shell_integration.rb +438 -0
  34. data/lib/space_architect/slugger.rb +16 -0
  35. data/lib/space_architect/space.rb +110 -0
  36. data/lib/space_architect/space_store.rb +319 -0
  37. data/lib/space_architect/state.rb +86 -0
  38. data/lib/space_architect/templates/architect.md.erb +48 -0
  39. data/lib/space_architect/templates/iteration.md.erb +66 -0
  40. data/lib/space_architect/terminal.rb +163 -0
  41. data/lib/space_architect/version.rb +5 -0
  42. data/lib/space_architect/warnings.rb +13 -0
  43. data/lib/space_architect/xdg.rb +33 -0
  44. data/lib/space_architect.rb +26 -0
  45. data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
  46. data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
  47. data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
  48. data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
  49. data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
  50. data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
  51. data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
  52. data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
  53. data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
  54. data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
  55. data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
  56. data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
  57. data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
  58. data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
  59. data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
  60. data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
  61. data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
  62. data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
  63. data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
  64. data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
  65. data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
  66. data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
  67. data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
  68. data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
  69. data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
  70. data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
  71. data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
  72. data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
  73. data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
  74. data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
  75. data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
  76. data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
  77. data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
  78. data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
  79. data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
  80. metadata +307 -0
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "space_architect/pristine"
5
+
6
+ module SpaceArchitect::Pristine
7
+ # CLI surface — thin translation layer between argv and the
8
+ # existing Config::Store / State::Store / Sync::Engine boundaries.
9
+ #
10
+ # The CLI never mutates a git repo directly; repo mutation happens
11
+ # inside the engine, which already upholds the no-data-loss
12
+ # invariant (PRD §1). The CLI's job is:
13
+ # 1. parse argv (via Dry::CLI's nested `register`)
14
+ # 2. load/mutate validated config (Config::Store) OR delegate to
15
+ # the engine / state store
16
+ # 3. translate Result to: out/err message + exit code
17
+ #
18
+ # Exit-code seam: each command records an `Outcome(exit_code:,
19
+ # message:)` (the thread-local stash) and writes the user-facing
20
+ # message to `out`/`err` via the injected IOs. The `bin/repo-tender`
21
+ # entrypoint reads the recorded Outcome and calls Kernel.exit with
22
+ # the code — see CLI.run below. Tests can inspect last_outcome
23
+ # in-process (no subprocess needed for unit tests); a subprocess
24
+ # Open3.capture3 covers the G3 "real exit" proof.
25
+ module CLI
26
+ # The Outcome value object. `exit_code` is 0 for success and 1
27
+ # for Failure-derived failures. `message` is the user-facing
28
+ # explanation already written to err (kept here so the
29
+ # entrypoint could log/record it in a future slice).
30
+ Outcome = Data.define(:exit_code, :message) do
31
+ def initialize(exit_code:, message: nil)
32
+ super
33
+ end
34
+ end
35
+
36
+ # Thread-local env hash. Defaults to ENV. Tests inject a temp
37
+ # HOME / XDG_* hash via Thread.current[:repo_tender_cli_env] =
38
+ # env_hash. The CLI's `make_paths` reads this to resolve the
39
+ # config/state file locations under the test's temp home.
40
+ def self.env
41
+ Thread.current[:repo_tender_cli_env] || ENV
42
+ end
43
+
44
+ # Thread-local Outcome stash. The most recent command's Outcome
45
+ # is read by CLI.run to set the process exit code.
46
+ def self.record_outcome(outcome)
47
+ Thread.current[:repo_tender_cli_outcome] = outcome
48
+ end
49
+
50
+ def self.last_outcome
51
+ Thread.current[:repo_tender_cli_outcome]
52
+ end
53
+
54
+ # Program-name-level invocations that must succeed (exit 0) with
55
+ # output on stdout. Dry::CLI has no root command registered, so it
56
+ # would otherwise route these through its "command not found" path
57
+ # (Usage → stderr → exit 1). We intercept ONLY the exact top-level
58
+ # forms here; a *leaf* help like `sync --help` (argv ["sync",
59
+ # "--help"]) and a *group* like `repo` / `repo --help` are NOT
60
+ # matched, so Dry::CLI keeps handling them as before (leaf help →
61
+ # stdout/exit 0; group → usage/stderr/exit 1, accepted per G7).
62
+ TOP_LEVEL_HELP = [[], ["--help"], ["-h"], ["help"]].freeze
63
+ VERSION_REQUEST = [["version"], ["--version"]].freeze
64
+
65
+ # Entrypoint. Called by bin/repo-tender. Intercepts the top-level
66
+ # help/version forms (stdout, exit 0), otherwise hands argv to
67
+ # Dry::CLI for command dispatch and translates the last Outcome to
68
+ # a process exit code. A `Interrupt` raised from inside command
69
+ # dispatch (most commonly: a SIGINT during a long-running
70
+ # `Shell.run`, e.g. at a `git` username prompt or mid-clone) is
71
+ # caught here and mapped to a clean exit code 130 (128 + SIGINT)
72
+ # with a single human line on stderr — the G2 ^C-hygiene fix
73
+ # (Slice 6). The reader-thread `IOError` noise that Open3 emits
74
+ # in the same scenario is suppressed at the `Shell.run` seam
75
+ # (see `lib/repo_tender/shell.rb`).
76
+ def self.run(argv, stdout, stderr)
77
+ return print_usage(stdout) if TOP_LEVEL_HELP.include?(argv)
78
+ return print_version(stdout) if VERSION_REQUEST.include?(argv)
79
+
80
+ begin
81
+ Dry::CLI.new(Registry).call(arguments: argv, out: stdout, err: stderr)
82
+ outcome = last_outcome
83
+ Kernel.exit(outcome&.exit_code || 0)
84
+ rescue Interrupt
85
+ # Map a user ^C to a clean exit-130 with a single human line.
86
+ # `Kernel.exit` raises `SystemExit` (callers/tests can rescue
87
+ # it to inspect the status). The `at_exit` handlers run,
88
+ # stdio is flushed, the process exits with code 130. We do
89
+ # NOT blanket-rescue `StandardError` and we do NOT make
90
+ # non-interrupt failures exit 0 (the outcome-translation path
91
+ # above is unchanged for the happy / non-Interrupt failure
92
+ # paths).
93
+ stderr.puts "interrupted"
94
+ Kernel.exit(130)
95
+ end
96
+ end
97
+
98
+ # Render the top-level command-group listing (reusing Dry::CLI's
99
+ # own Usage formatter so it stays in sync with the registry) to
100
+ # stdout and exit 0.
101
+ def self.print_usage(stdout)
102
+ stdout.puts Dry::CLI::Usage.call(Registry.get([]))
103
+ Kernel.exit(0)
104
+ end
105
+
106
+ # Print the gem version to stdout and exit 0.
107
+ def self.print_version(stdout)
108
+ stdout.puts SpaceArchitect::Pristine::VERSION
109
+ Kernel.exit(0)
110
+ end
111
+
112
+ # Internal: build a Paths instance scoped to the active env
113
+ # (Thread.current[:repo_tender_cli_env] || ENV). Every command
114
+ # uses this so tests can inject a temp home without mutating
115
+ # the real ENV.
116
+ def self.make_paths
117
+ Paths.new(environment: env)
118
+ end
119
+
120
+ # The Dry::CLI::Registry-extended module. The subcommand files
121
+ # (cli/repo.rb, cli/org.rb, …) call `register "x" do |p| ... end`
122
+ # on this module at load time.
123
+ module Registry
124
+ extend Dry::CLI::Registry
125
+ end
126
+ end
127
+ end
128
+
129
+ # Subcommand files — each defines its command classes and
130
+ # registers them under their group prefix.
131
+ require "space_architect/pristine/cli/repo"
132
+ require "space_architect/pristine/cli/org"
133
+ require "space_architect/pristine/cli/sync"
134
+ require "space_architect/pristine/cli/status"
135
+ require "space_architect/pristine/cli/config"
136
+ require "space_architect/pristine/cli/daemon"
137
+ require "space_architect/pristine/cli/clone"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "dry/monads"
5
+ require "space_architect/pristine/shell"
6
+
7
+ module SpaceArchitect::Pristine
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 { SpaceArchitect::Pristine::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
+ candidates = Dir.glob(File.join(@base_dir, "*", "*", name))
41
+ case candidates.length
42
+ when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
43
+ when 1 then Success(candidates.first)
44
+ else ambiguous(name, candidates, "owner/name or host/owner/name")
45
+ end
46
+ when 2
47
+ owner, repo_name = parts
48
+ candidates = Dir.glob(File.join(@base_dir, "*", owner, repo_name))
49
+ case candidates.length
50
+ when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
51
+ when 1 then Success(candidates.first)
52
+ else ambiguous(name, candidates, "host/owner/name")
53
+ end
54
+ when 3
55
+ path = File.join(@base_dir, *parts)
56
+ File.directory?(path) ? Success(path) : Failure("#{name.inspect} not found under base_dir #{@base_dir}")
57
+ else
58
+ Failure("invalid repo reference: #{name.inspect}")
59
+ end
60
+ end
61
+
62
+ def ambiguous(name, candidates, hint)
63
+ list = candidates.map { |c| " #{c.delete_prefix("#{@base_dir}/")}" }.join("\n")
64
+ Failure("ambiguous name #{name.inspect}; qualify with #{hint}:\n#{list}")
65
+ end
66
+
67
+ def copy(src, into)
68
+ leaf = File.basename(src)
69
+ dest = File.join(File.expand_path(into), leaf)
70
+ return Failure("destination already exists: #{dest}") if File.exist?(dest)
71
+ result = @shell.run("cp", "-Rc", src, dest)
72
+ result.success? ? Success(dest) : result
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+ require "dry/validation/extensions/monads"
5
+ require "dry/monads"
6
+
7
+ module SpaceArchitect::Pristine
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
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module SpaceArchitect::Pristine
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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/struct"
4
+ require "dry/types"
5
+
6
+ module SpaceArchitect::Pristine
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
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "dry/monads"
6
+ require "space_architect/pristine/config/model"
7
+ require "space_architect/pristine/config/contract"
8
+
9
+ module SpaceArchitect::Pristine
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 = SpaceArchitect::Pristine::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/repo_tender/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
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "space_architect/pristine/config/model"
5
+
6
+ module SpaceArchitect::Pristine
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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "space_architect/pristine/forge/client"
5
+ require "space_architect/pristine/shell"
6
+ require "space_architect/pristine/config/model"
7
+
8
+ module SpaceArchitect::Pristine
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