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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +284 -0
- data/exe/architect +13 -0
- data/exe/space +13 -0
- data/lib/space_architect/architect_mission.rb +436 -0
- data/lib/space_architect/atomic_write.rb +21 -0
- data/lib/space_architect/cli/architect.rb +388 -0
- data/lib/space_architect/cli/config.rb +61 -0
- data/lib/space_architect/cli/current.rb +22 -0
- data/lib/space_architect/cli/helpers.rb +117 -0
- data/lib/space_architect/cli/init.rb +35 -0
- data/lib/space_architect/cli/list.rb +30 -0
- data/lib/space_architect/cli/new.rb +43 -0
- data/lib/space_architect/cli/options.rb +12 -0
- data/lib/space_architect/cli/path.rb +22 -0
- data/lib/space_architect/cli/repo.rb +88 -0
- data/lib/space_architect/cli/shell.rb +137 -0
- data/lib/space_architect/cli/show.rb +27 -0
- data/lib/space_architect/cli/space.rb +35 -0
- data/lib/space_architect/cli/src.rb +32 -0
- data/lib/space_architect/cli/status.rb +39 -0
- data/lib/space_architect/cli/use.rb +23 -0
- data/lib/space_architect/cli.rb +102 -0
- data/lib/space_architect/config.rb +152 -0
- data/lib/space_architect/dispatcher.rb +21 -0
- data/lib/space_architect/errors.rb +14 -0
- data/lib/space_architect/git_client.rb +49 -0
- data/lib/space_architect/harness.rb +168 -0
- data/lib/space_architect/mise_client.rb +37 -0
- data/lib/space_architect/repo_reference.rb +19 -0
- data/lib/space_architect/repo_resolver.rb +167 -0
- data/lib/space_architect/shell_integration.rb +438 -0
- data/lib/space_architect/slugger.rb +16 -0
- data/lib/space_architect/space.rb +110 -0
- data/lib/space_architect/space_store.rb +319 -0
- data/lib/space_architect/state.rb +86 -0
- data/lib/space_architect/templates/architect.md.erb +48 -0
- data/lib/space_architect/templates/iteration.md.erb +66 -0
- data/lib/space_architect/terminal.rb +163 -0
- data/lib/space_architect/version.rb +5 -0
- data/lib/space_architect/warnings.rb +13 -0
- data/lib/space_architect/xdg.rb +33 -0
- data/lib/space_architect.rb +26 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
- data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
- data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
- data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
- data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
- data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
- 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
|