gitbroker 0.3.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/exe/gitbroker +10 -0
- data/lib/gitbroker/agents/base.rb +48 -0
- data/lib/gitbroker/agents/claude.rb +92 -0
- data/lib/gitbroker/agents/codex.rb +72 -0
- data/lib/gitbroker/agents.rb +18 -0
- data/lib/gitbroker/api.rb +53 -0
- data/lib/gitbroker/cable_client.rb +213 -0
- data/lib/gitbroker/cli.rb +199 -0
- data/lib/gitbroker/config.rb +88 -0
- data/lib/gitbroker/daemon.rb +166 -0
- data/lib/gitbroker/explain_verifier.rb +31 -0
- data/lib/gitbroker/gh_check.rb +22 -0
- data/lib/gitbroker/git_context.rb +105 -0
- data/lib/gitbroker/hook.rb +50 -0
- data/lib/gitbroker/hooks_installer.rb +114 -0
- data/lib/gitbroker/launcher.rb +62 -0
- data/lib/gitbroker/lifecycle.rb +114 -0
- data/lib/gitbroker/local_relay.rb +100 -0
- data/lib/gitbroker/login.rb +61 -0
- data/lib/gitbroker/neutralizer.rb +80 -0
- data/lib/gitbroker/process_scanner.rb +103 -0
- data/lib/gitbroker/repo_scanner.rb +50 -0
- data/lib/gitbroker/run_script.rb +58 -0
- data/lib/gitbroker/runner.rb +41 -0
- data/lib/gitbroker/service_installer.rb +145 -0
- data/lib/gitbroker/skill_installer.rb +30 -0
- data/lib/gitbroker/task_files.rb +90 -0
- data/lib/gitbroker/task_runner.rb +258 -0
- data/lib/gitbroker/url_handler_installer.rb +87 -0
- data/lib/gitbroker/version.rb +5 -0
- data/lib/gitbroker/worktree.rb +93 -0
- data/lib/gitbroker.rb +39 -0
- data/plugin/.claude-plugin/plugin.json +5 -0
- data/plugin/skills/gitbroker/SKILL.md +36 -0
- data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
- metadata +95 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
|
|
8
|
+
module Gitbroker
|
|
9
|
+
# Adds `gitbroker hook <event>` command hooks to the user-level Claude Code settings (opt-in, main spec §18.4).
|
|
10
|
+
# Merges into the existing file: every other setting and the user's own hooks stay, our entries are replaced (never
|
|
11
|
+
# duplicated), an unchanged file is not rewritten, and a changed one is backed up first. No token is ever written.
|
|
12
|
+
class HooksInstaller
|
|
13
|
+
EVENTS = %w[SessionStart UserPromptSubmit Stop SessionEnd].freeze
|
|
14
|
+
OURS = /gitbroker hook (?:#{EVENTS.join("|")})\z/
|
|
15
|
+
TIMEOUT = 5
|
|
16
|
+
|
|
17
|
+
attr_reader :settings_path
|
|
18
|
+
|
|
19
|
+
def self.default(env: ENV, home: Dir.home)
|
|
20
|
+
new(settings_path: File.join(env["CLAUDE_CONFIG_DIR"] || File.join(home, ".claude"), "settings.json"),
|
|
21
|
+
command_prefix: "#{Shellwords.escape(RbConfig.ruby)} #{Shellwords.escape(ServiceInstaller::EXE)}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(settings_path:, command_prefix:, clock: -> { Time.now })
|
|
25
|
+
@settings_path = settings_path
|
|
26
|
+
@command_prefix = command_prefix
|
|
27
|
+
@clock = clock
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def installed?
|
|
31
|
+
hooks = read["hooks"] || {}
|
|
32
|
+
EVENTS.all? { |event| Array(hooks[event]).any? { ours_group?(_1) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the backup path, or nil when there was nothing to back up (new file) or nothing changed.
|
|
36
|
+
def install!
|
|
37
|
+
data = read
|
|
38
|
+
hooks = strip_ours(data["hooks"] || {})
|
|
39
|
+
EVENTS.each { |event| hooks[event] = Array(hooks[event]) + [ group_for(event) ] }
|
|
40
|
+
write(data, data.merge("hooks" => hooks))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def uninstall!
|
|
44
|
+
return nil unless File.exist?(settings_path)
|
|
45
|
+
|
|
46
|
+
data = read
|
|
47
|
+
return nil unless data.key?("hooks")
|
|
48
|
+
|
|
49
|
+
hooks = strip_ours(data["hooks"]).reject { |_event, groups| groups.empty? }
|
|
50
|
+
updated = data.dup
|
|
51
|
+
hooks.empty? ? updated.delete("hooks") : updated["hooks"] = hooks
|
|
52
|
+
write(data, updated)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def group_for(event) = { "hooks" => [ { "type" => "command", "command" => "#{@command_prefix} hook #{event}", "timeout" => TIMEOUT } ] }
|
|
58
|
+
def ours_hook?(hook) = hook.is_a?(Hash) && hook["command"].to_s.match?(OURS)
|
|
59
|
+
def ours_group?(group) = group.is_a?(Hash) && Array(group["hooks"]).any? { ours_hook?(_1) }
|
|
60
|
+
|
|
61
|
+
def strip_ours(hooks)
|
|
62
|
+
hooks.to_h do |event, groups|
|
|
63
|
+
kept = Array(groups).filter_map do |group|
|
|
64
|
+
next group unless group.is_a?(Hash) && group["hooks"].is_a?(Array)
|
|
65
|
+
|
|
66
|
+
remaining = group["hooks"].reject { ours_hook?(_1) }
|
|
67
|
+
next nil if remaining.empty? && group["hooks"].any?
|
|
68
|
+
|
|
69
|
+
group.merge("hooks" => remaining)
|
|
70
|
+
end
|
|
71
|
+
[ event, kept ]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def read
|
|
76
|
+
return {} unless File.exist?(settings_path)
|
|
77
|
+
|
|
78
|
+
data = JSON.parse(File.read(settings_path))
|
|
79
|
+
raise Error, "#{settings_path} is not a JSON object; fix it first (nothing was changed)" unless data.is_a?(Hash)
|
|
80
|
+
unless data["hooks"].nil? || data["hooks"].is_a?(Hash)
|
|
81
|
+
raise Error, "#{settings_path} has a \"hooks\" value that is not an object; fix it first (nothing was changed)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
data
|
|
85
|
+
rescue JSON::ParserError
|
|
86
|
+
raise Error, "#{settings_path} is not valid JSON; fix it first (nothing was changed)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write(before, after)
|
|
90
|
+
exists = File.exist?(settings_path)
|
|
91
|
+
return nil if exists && before == after
|
|
92
|
+
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(settings_path))
|
|
94
|
+
backup = exists ? backup! : nil
|
|
95
|
+
temp = "#{settings_path}.gitbroker-#{Process.pid}.tmp"
|
|
96
|
+
File.write(temp, "#{JSON.pretty_generate(after)}\n")
|
|
97
|
+
File.chmod(File.stat(settings_path).mode & 0o777, temp) if exists
|
|
98
|
+
File.rename(temp, settings_path)
|
|
99
|
+
backup
|
|
100
|
+
ensure
|
|
101
|
+
FileUtils.rm_f(temp) if temp
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Never overwrites an earlier backup, even within the same second.
|
|
105
|
+
def backup!
|
|
106
|
+
base = "#{settings_path}.gitbroker-backup-#{@clock.call.utc.strftime("%Y%m%d%H%M%S")}"
|
|
107
|
+
path = base
|
|
108
|
+
counter = 0
|
|
109
|
+
path = "#{base}-#{counter += 1}" while File.exist?(path)
|
|
110
|
+
FileUtils.cp(settings_path, path, preserve: true)
|
|
111
|
+
path
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Gitbroker
|
|
8
|
+
# Opens a visible terminal running the task's run script (spec §4.2 step 6). The user sees and drives the agent.
|
|
9
|
+
class Launcher
|
|
10
|
+
def initialize(name:, runner: Runner.new, platform: Gitbroker.platform,
|
|
11
|
+
warp_dir: File.join(Dir.home, ".warp", "launch_configurations"))
|
|
12
|
+
raise Error, "Unknown terminal #{name.inspect}. Use one of: #{Config::TERMINALS.join(", ")}" unless Config::TERMINALS.include?(name)
|
|
13
|
+
|
|
14
|
+
@name = name
|
|
15
|
+
@runner = runner
|
|
16
|
+
@platform = platform
|
|
17
|
+
@warp_dir = warp_dir
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def launch!(script:, cwd:, title:)
|
|
21
|
+
commands(script:, cwd:, title:).each { @runner.run!(*_1) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def commands(script:, cwd:, title:)
|
|
25
|
+
title = title.to_s.gsub(/[^\w.#-]/, "-")
|
|
26
|
+
line = "/bin/bash #{Shellwords.escape(script)}"
|
|
27
|
+
|
|
28
|
+
case @name
|
|
29
|
+
when "terminal"
|
|
30
|
+
[ [ "osascript", "-e", %(tell application "Terminal" to do script #{applescript(line)}),
|
|
31
|
+
"-e", %(tell application "Terminal" to activate) ] ]
|
|
32
|
+
when "iterm2"
|
|
33
|
+
[ [ "osascript", "-e", %(tell application "iTerm" to create window with default profile command #{applescript(line)}),
|
|
34
|
+
"-e", %(tell application "iTerm" to activate) ] ]
|
|
35
|
+
when "ghostty"
|
|
36
|
+
@platform == "macos" ? [ [ "open", "-na", "Ghostty", "--args", "-e", "/bin/bash", script ] ] : [ [ "ghostty", "-e", "/bin/bash", script ] ]
|
|
37
|
+
when "warp"
|
|
38
|
+
[ [ @platform == "macos" ? "open" : "xdg-open", "warp://launch/#{warp_config(line:, cwd:, title:)}" ] ]
|
|
39
|
+
when "tmux"
|
|
40
|
+
if @runner.success?("tmux", "has-session")
|
|
41
|
+
[ [ "tmux", "new-window", "-n", title, "-c", cwd, line ] ]
|
|
42
|
+
else
|
|
43
|
+
[ [ "tmux", "new-session", "-d", "-s", "gitbroker", "-n", title, "-c", cwd, line ] ]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# An AppleScript string literal: backslashes and double quotes escaped.
|
|
51
|
+
def applescript(text) = %("#{text.gsub("\\") { "\\\\" }.gsub('"') { '\\"' }}")
|
|
52
|
+
|
|
53
|
+
def warp_config(line:, cwd:, title:)
|
|
54
|
+
FileUtils.mkdir_p(@warp_dir)
|
|
55
|
+
file = "gitbroker-#{title}.yaml"
|
|
56
|
+
config = { "name" => "GitBroker #{title}",
|
|
57
|
+
"windows" => [ { "tabs" => [ { "title" => title, "layout" => { "cwd" => cwd, "commands" => [ { "exec" => line } ] } } ] } ] }
|
|
58
|
+
File.write(File.join(@warp_dir, file), config.to_yaml)
|
|
59
|
+
file
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Gitbroker
|
|
7
|
+
# Stopping, restarting and spawning the companion on this machine: used by `gitbroker stop` / `restart` / `launch` and
|
|
8
|
+
# by the daemon when the website sends a "control" message. A background service (launchd / systemd) is stopped and
|
|
9
|
+
# restarted through its manager, otherwise it would resurrect a stopped daemon (KeepAlive / Restart=always).
|
|
10
|
+
class Lifecycle
|
|
11
|
+
PID_FILE = "daemon.pid"
|
|
12
|
+
WAIT = 10
|
|
13
|
+
|
|
14
|
+
def initialize(dir:, home: Dir.home, service: ServiceInstaller.new(home:), runner: Runner.new, exe: ServiceInstaller::EXE,
|
|
15
|
+
ruby: RbConfig.ruby, spawner: nil, sleeper: ->(seconds) { sleep(seconds) }, pid: Process.pid)
|
|
16
|
+
@dir = dir
|
|
17
|
+
@home = home
|
|
18
|
+
@service = service
|
|
19
|
+
@runner = runner
|
|
20
|
+
@exe = exe
|
|
21
|
+
@ruby = ruby
|
|
22
|
+
@spawner = spawner || method(:spawn_process)
|
|
23
|
+
@sleeper = sleeper
|
|
24
|
+
@pid = pid
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pid_path = File.join(@dir, PID_FILE)
|
|
28
|
+
def service_installed? = @service.installed?
|
|
29
|
+
def log_path = File.join(@home, ".config", "gitbroker", "companion.log")
|
|
30
|
+
|
|
31
|
+
def write_pid
|
|
32
|
+
FileUtils.mkdir_p(@dir)
|
|
33
|
+
File.write(pid_path, @pid.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear_pid
|
|
37
|
+
File.delete(pid_path) if File.exist?(pid_path) && File.read(pid_path).to_i == @pid
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Used by the daemon itself: false when no service is installed or the manager refused.
|
|
43
|
+
def stop_service = @service.installed? && @service.stop!
|
|
44
|
+
def restart_service = @service.installed? && @service.restart!
|
|
45
|
+
|
|
46
|
+
# Starts the background service, or the daemon itself when none is installed. False when a service was refused.
|
|
47
|
+
def launch
|
|
48
|
+
return true if @service.start!
|
|
49
|
+
|
|
50
|
+
spawn_daemon
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def spawn_daemon
|
|
54
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
55
|
+
@spawner.call([ @ruby, @exe, "start" ], log_path)
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The service (if any) is stopped through its manager; a daemon started by hand is signalled by its pid file.
|
|
60
|
+
def stop
|
|
61
|
+
stopped = @service.installed? && @service.stop!
|
|
62
|
+
terminated = terminate_running
|
|
63
|
+
stopped || terminated
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Through the manager when a service is installed (it loads the freshly installed gem), otherwise stop and start again.
|
|
67
|
+
def restart
|
|
68
|
+
return true if @service.installed? && @service.restart!
|
|
69
|
+
|
|
70
|
+
terminate_running
|
|
71
|
+
spawn_daemon
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The running daemon's pid, only when it is alive and really a gitbroker process (a stale file may name a recycled pid).
|
|
75
|
+
def running_pid
|
|
76
|
+
pid = File.exist?(pid_path) ? File.read(pid_path).to_i : 0
|
|
77
|
+
return nil if pid <= 1 || pid == @pid
|
|
78
|
+
|
|
79
|
+
Process.kill(0, pid)
|
|
80
|
+
@runner.capture("ps", "-p", pid.to_s, "-o", "command=").include?("gitbroker") ? pid : nil
|
|
81
|
+
rescue SystemCallError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def terminate_running
|
|
88
|
+
pid = running_pid
|
|
89
|
+
return false unless pid
|
|
90
|
+
|
|
91
|
+
Process.kill("TERM", pid)
|
|
92
|
+
WAIT.times do
|
|
93
|
+
return true unless alive?(pid)
|
|
94
|
+
|
|
95
|
+
@sleeper.call(0.5)
|
|
96
|
+
end
|
|
97
|
+
Process.kill("KILL", pid) if alive?(pid)
|
|
98
|
+
true
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def alive?(pid)
|
|
104
|
+
Process.kill(0, pid)
|
|
105
|
+
true
|
|
106
|
+
rescue SystemCallError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def spawn_process(argv, log)
|
|
111
|
+
Process.detach(Process.spawn(*argv, in: File::NULL, out: [ log, "a" ], err: [ log, "a" ], pgroup: true))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "io/wait"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Gitbroker
|
|
9
|
+
# Unix socket between `gitbroker hook` (one JSON object per line) and the daemon. Owner-only (0600); the daemon
|
|
10
|
+
# forwards over its authenticated Action Cable connection.
|
|
11
|
+
class LocalRelay
|
|
12
|
+
MAX_LINE = 4_096
|
|
13
|
+
MAX_QUEUE = 1_000
|
|
14
|
+
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
def self.send_line(path, line, timeout: 0.2)
|
|
18
|
+
return false unless File.socket?(path)
|
|
19
|
+
|
|
20
|
+
socket = Socket.new(:UNIX, :STREAM)
|
|
21
|
+
begin
|
|
22
|
+
socket.connect_nonblock(Socket.sockaddr_un(path))
|
|
23
|
+
rescue IO::WaitWritable
|
|
24
|
+
return false unless socket.wait_writable(timeout)
|
|
25
|
+
end
|
|
26
|
+
written = socket.write_nonblock("#{line}\n", exception: false)
|
|
27
|
+
return false if written == :wait_writable && !socket.wait_writable(timeout)
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
rescue SystemCallError, IOError, ArgumentError
|
|
31
|
+
false
|
|
32
|
+
ensure
|
|
33
|
+
socket&.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(path:)
|
|
37
|
+
@path = path
|
|
38
|
+
@queue = Queue.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def start
|
|
42
|
+
if File.exist?(@path)
|
|
43
|
+
raise Error, "Another gitbroker daemon is already running (#{@path})" if self.class.send_line(@path, "{}", timeout: 0.1)
|
|
44
|
+
|
|
45
|
+
File.delete(@path)
|
|
46
|
+
end
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
48
|
+
previous = File.umask(0o177)
|
|
49
|
+
begin
|
|
50
|
+
@server = UNIXServer.new(@path)
|
|
51
|
+
ensure
|
|
52
|
+
File.umask(previous)
|
|
53
|
+
end
|
|
54
|
+
File.chmod(0o600, @path)
|
|
55
|
+
@thread = Thread.new { accept_loop }
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def drain
|
|
60
|
+
items = []
|
|
61
|
+
items << @queue.pop(true) while items.size < MAX_QUEUE && !@queue.empty?
|
|
62
|
+
items
|
|
63
|
+
rescue ThreadError
|
|
64
|
+
items
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only a relay that started removes the socket file, so a failed start never unlinks a live daemon's socket.
|
|
68
|
+
def stop
|
|
69
|
+
return unless @server
|
|
70
|
+
|
|
71
|
+
@server.close
|
|
72
|
+
@thread&.kill
|
|
73
|
+
File.delete(@path) if File.socket?(@path)
|
|
74
|
+
@server = nil
|
|
75
|
+
rescue IOError, SystemCallError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def accept_loop
|
|
82
|
+
loop { read_client(@server.accept) }
|
|
83
|
+
rescue IOError, SystemCallError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def read_client(client)
|
|
88
|
+
return unless client.wait_readable(1)
|
|
89
|
+
|
|
90
|
+
line = client.gets(MAX_LINE)
|
|
91
|
+
data = line && JSON.parse(line)
|
|
92
|
+
# `{}` is the liveness probe from #start; empty objects are dropped.
|
|
93
|
+
@queue << data if data.is_a?(Hash) && !data.empty? && @queue.size < MAX_QUEUE
|
|
94
|
+
rescue JSON::ParserError, IOError, SystemCallError
|
|
95
|
+
nil
|
|
96
|
+
ensure
|
|
97
|
+
client.close
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Gitbroker
|
|
6
|
+
# RFC 8628-style device-code pairing against POST /device_authorizations (spec §4.1).
|
|
7
|
+
class Login
|
|
8
|
+
def self.open_browser(url)
|
|
9
|
+
system(Gitbroker.platform == "macos" ? "open" : "xdg-open", url, out: File::NULL, err: File::NULL)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(config:, api: Api.new(server: config.server), out: $stdout, name: Socket.gethostname,
|
|
13
|
+
sleeper: ->(seconds) { sleep(seconds) }, opener: ->(url) { Login.open_browser(url) }, clock: -> { Time.now })
|
|
14
|
+
@config = config
|
|
15
|
+
@api = api
|
|
16
|
+
@out = out
|
|
17
|
+
@name = name
|
|
18
|
+
@sleeper = sleeper
|
|
19
|
+
@opener = opener
|
|
20
|
+
@clock = clock
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
auth = @api.post("/device_authorizations", { client_name: @name, platform: Gitbroker.platform })
|
|
25
|
+
# No pre-filled link: the user types the code on the plain page.
|
|
26
|
+
@out.puts "To pair this machine, open #{auth.fetch("verification_uri")} and type the code: #{auth.fetch("user_code")}"
|
|
27
|
+
@out.puts "Approve only if the page shows this machine's name (#{@name}) and the code above."
|
|
28
|
+
@opener.call(auth.fetch("verification_uri"))
|
|
29
|
+
poll(auth)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def poll(auth)
|
|
35
|
+
interval = auth.fetch("interval", 5).to_i
|
|
36
|
+
deadline = @clock.call + auth.fetch("expires_in", 600).to_i
|
|
37
|
+
|
|
38
|
+
loop do
|
|
39
|
+
raise Error, "The pairing code expired. Run `gitbroker login` again." if @clock.call > deadline
|
|
40
|
+
|
|
41
|
+
@sleeper.call(interval)
|
|
42
|
+
begin
|
|
43
|
+
# RFC 8628: the device code goes in the body, never the URL (request paths end up in logs).
|
|
44
|
+
token = @api.post("/device_authorizations/token", { device_code: auth.fetch("device_code") })
|
|
45
|
+
rescue Api::HttpError => e
|
|
46
|
+
case e.body["error"]
|
|
47
|
+
when "authorization_pending" then next
|
|
48
|
+
when "slow_down" then interval += 5
|
|
49
|
+
when "expired_token" then raise Error, "The pairing code expired. Run `gitbroker login` again."
|
|
50
|
+
else raise Error, "Pairing failed: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@config.save_credentials!(device_token: token.fetch("access_token"), device_id: token.fetch("device_id"))
|
|
56
|
+
@out.puts "Paired. Next: `gitbroker install-service` (or `gitbroker start`)."
|
|
57
|
+
return token["device_id"]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Gitbroker
|
|
6
|
+
# Untrusted heads only (spec §4.2 step 3, §5.1; understanding spec §5.3): the PR's own agent configuration never
|
|
7
|
+
# becomes active. Repo-controlled config is removed (restoring it from base is not enough: base hooks would still
|
|
8
|
+
# run); CLAUDE.md / AGENTS.md come back from the base branch. Every touched tracked path is marked --skip-worktree,
|
|
9
|
+
# so `git add -A` can never commit the neutralization.
|
|
10
|
+
class Neutralizer
|
|
11
|
+
CONFIG_DIRS = %w[.claude .codex].freeze
|
|
12
|
+
REMOVE = [ *CONFIG_DIRS.flat_map { [ ":(glob)**/#{_1}", ":(glob)**/#{_1}/**" ] }, ":(glob)**/.mcp.json",
|
|
13
|
+
":(glob)**/AGENTS.override.md", ":(glob)**/CLAUDE.local.md", ":(glob)**/.gitattributes" ].freeze
|
|
14
|
+
RESTORE = [ ":(glob)**/CLAUDE.md", ":(glob)**/AGENTS.md" ].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(runner: Runner.new)
|
|
17
|
+
@runner = runner
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def neutralize!(dir, repo_path:, number:, base_ref:)
|
|
21
|
+
raise Error, "Invalid base branch #{base_ref.inspect}" unless Worktree.valid_ref?(base_ref)
|
|
22
|
+
raise Error, "Invalid PR number #{number.inspect}" unless number.to_s.match?(/\A[1-9]\d{0,9}\z/)
|
|
23
|
+
|
|
24
|
+
@dir = File.realpath(dir)
|
|
25
|
+
base = "refs/gitbroker/base-#{number}"
|
|
26
|
+
@runner.git(repo_path, "fetch", "--quiet", "origin", "+refs/heads/#{base_ref}:#{base}")
|
|
27
|
+
|
|
28
|
+
removed = paths(@runner.git(@dir, "ls-files", "-z", "--", *REMOVE))
|
|
29
|
+
removed.each { delete(_1) }
|
|
30
|
+
removed += untracked_config
|
|
31
|
+
(CONFIG_DIRS + [ ".mcp.json" ]).each { delete(_1, recursive: true) }
|
|
32
|
+
|
|
33
|
+
restored = restore_from(base)
|
|
34
|
+
touched = (removed + restored).uniq
|
|
35
|
+
tracked = touched.empty? ? [] : paths(@runner.git(@dir, "ls-files", "-z", "--", *touched.map { ":(literal)#{_1}" }))
|
|
36
|
+
@runner.git(@dir, "update-index", "--skip-worktree", "--", *tracked) if tracked.any?
|
|
37
|
+
touched.sort
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# CLAUDE.md / AGENTS.md the PR modified come back from base; ones it added are deleted. Ones it deleted stay
|
|
43
|
+
# deleted: restored, they would be untracked files that `git add -A` could commit.
|
|
44
|
+
def restore_from(base)
|
|
45
|
+
fields = paths(@runner.git(@dir, "diff", "-z", "--name-status", "--no-renames", "#{base}...HEAD", "--", *RESTORE))
|
|
46
|
+
fields.each_slice(2).filter_map do |status, path|
|
|
47
|
+
case status
|
|
48
|
+
when "A" then delete(path)
|
|
49
|
+
when "D" then next
|
|
50
|
+
else @runner.git(@dir, "restore", "--source=#{base}", "--worktree", "--", ":(literal)#{path}")
|
|
51
|
+
end
|
|
52
|
+
path
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Untracked agent config at the top of the worktree (never from the PR, but never trusted either).
|
|
57
|
+
def untracked_config
|
|
58
|
+
(CONFIG_DIRS + [ ".mcp.json" ]).flat_map do |name|
|
|
59
|
+
path = File.join(@dir, name)
|
|
60
|
+
next [] unless File.exist?(path) && !File.symlink?(path)
|
|
61
|
+
next [ name ] unless File.directory?(path)
|
|
62
|
+
|
|
63
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: path).reject { File.directory?(File.join(path, _1)) }.map { "#{name}/#{_1}" }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only ever deletes inside the worktree: a path whose parent resolves elsewhere (a symlinked directory) is skipped.
|
|
68
|
+
def delete(relative, recursive: false)
|
|
69
|
+
full = File.join(@dir, relative)
|
|
70
|
+
parent = File.realpath(File.dirname(full))
|
|
71
|
+
return unless parent == @dir || parent.start_with?("#{@dir}/")
|
|
72
|
+
|
|
73
|
+
recursive ? FileUtils.rm_rf(full) : FileUtils.rm_f(full)
|
|
74
|
+
rescue SystemCallError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def paths(output) = output.split("\0").reject(&:empty?)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Gitbroker
|
|
6
|
+
# Every tick (30 s): which known coding agents run, and in which GitHub checkout. A process says nothing about
|
|
7
|
+
# working or waiting, so this reports only "running" and "exit" (main spec §18.4). A process's cwd is untrusted:
|
|
8
|
+
# it is resolved from .git files only, never by running git there.
|
|
9
|
+
class ProcessScanner
|
|
10
|
+
AGENTS = { "claude" => "claude_code", "codex" => "codex", "aider" => "aider", "cursor-agent" => "cursor" }.freeze
|
|
11
|
+
LAUNCHERS = /\A(?:node|python[\d.]*|bun|deno)\z/
|
|
12
|
+
|
|
13
|
+
def self.agent_for(args)
|
|
14
|
+
tokens = args.to_s.split
|
|
15
|
+
name = File.basename(tokens[0].to_s)
|
|
16
|
+
name = File.basename(tokens[1].to_s) if name.match?(LAUNCHERS)
|
|
17
|
+
AGENTS[name.sub(/\.(?:js|mjs|cjs|py)\z/, "")]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# skip_claude: true/false, or a callable checked on every scan (Claude sessions with hooks installed report
|
|
21
|
+
# themselves, with real working/waiting status).
|
|
22
|
+
def initialize(runner: Runner.new, platform: Gitbroker.platform, skip_claude: false, exclude: [], proc_root: "/proc",
|
|
23
|
+
resolver: ->(cwd) { GitContext.resolve(cwd) }, clock: -> { Time.now })
|
|
24
|
+
@runner = runner
|
|
25
|
+
@platform = platform
|
|
26
|
+
@skip_claude = skip_claude
|
|
27
|
+
@exclude = exclude.map { File.expand_path(_1) }
|
|
28
|
+
@proc_root = proc_root
|
|
29
|
+
@resolver = resolver
|
|
30
|
+
@clock = clock
|
|
31
|
+
@known = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def scan
|
|
35
|
+
now = @clock.call.utc.iso8601
|
|
36
|
+
current = live_agents
|
|
37
|
+
running = current.map do |id, info|
|
|
38
|
+
{ "event" => "running", "agent" => info[:agent], "external_session_id" => id, "occurred_at" => now }.merge(info[:context])
|
|
39
|
+
end
|
|
40
|
+
exited = (@known.keys - current.keys).map do |id|
|
|
41
|
+
{ "event" => "exit", "agent" => @known[id], "external_session_id" => id, "occurred_at" => now }
|
|
42
|
+
end
|
|
43
|
+
@known = current.transform_values { _1[:agent] }
|
|
44
|
+
running + exited
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# { "proc-<pid>" => { agent:, context: } }, one per (agent, cwd), only in GitHub checkouts outside `exclude`.
|
|
50
|
+
def live_agents
|
|
51
|
+
agents = agent_processes
|
|
52
|
+
cwds = working_dirs(agents.keys)
|
|
53
|
+
seen = {}
|
|
54
|
+
agents.sort.each_with_object({}) do |(pid, agent), current|
|
|
55
|
+
cwd = cwds[pid]
|
|
56
|
+
next if cwd.nil? || seen[[ agent, cwd ]] || excluded?(cwd)
|
|
57
|
+
|
|
58
|
+
context = @resolver.call(cwd) or next
|
|
59
|
+
seen[[ agent, cwd ]] = true
|
|
60
|
+
current["proc-#{pid}"] = { agent:, context: }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def skip_claude? = @skip_claude.respond_to?(:call) ? @skip_claude.call : @skip_claude
|
|
65
|
+
def excluded?(cwd) = @exclude.any? { cwd == _1 || cwd.start_with?("#{_1}/") }
|
|
66
|
+
|
|
67
|
+
def agent_processes
|
|
68
|
+
skip_claude = skip_claude?
|
|
69
|
+
@runner.run!("ps", "-Ao", "pid=,args=").lines.each_with_object({}) do |line, found|
|
|
70
|
+
pid, args = line.strip.split(" ", 2)
|
|
71
|
+
next unless pid.to_s.match?(/\A\d+\z/) && pid.to_i != Process.pid
|
|
72
|
+
|
|
73
|
+
agent = self.class.agent_for(args)
|
|
74
|
+
next if agent.nil? || (skip_claude && agent == "claude_code")
|
|
75
|
+
|
|
76
|
+
found[pid.to_i] = agent
|
|
77
|
+
end
|
|
78
|
+
rescue Error
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def working_dirs(pids)
|
|
83
|
+
return {} if pids.empty?
|
|
84
|
+
return pids.to_h { [ _1, proc_cwd(_1) ] }.compact if @platform == "linux"
|
|
85
|
+
|
|
86
|
+
cwds = {}
|
|
87
|
+
pid = nil
|
|
88
|
+
@runner.capture("lsof", "-a", "-d", "cwd", "-Fn", "-p", pids.sort.join(",")).each_line do |line|
|
|
89
|
+
case line[0]
|
|
90
|
+
when "p" then pid = line[1..].to_i
|
|
91
|
+
when "n" then cwds[pid] = line[1..].chomp if pid
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
cwds
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def proc_cwd(pid)
|
|
98
|
+
File.readlink(File.join(@proc_root, pid.to_s, "cwd"))
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitbroker
|
|
4
|
+
# Finds GitHub checkouts under the configured roots and reports them (DeviceRepository), so a card only offers
|
|
5
|
+
# agents where a checkout exists. Reads .git/config as a file; never runs git in scanned directories.
|
|
6
|
+
class RepoScanner
|
|
7
|
+
GITHUB_URL = %r{\A(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([\w.-]+)/([\w.-]+?)(?:\.git)?/?\z}
|
|
8
|
+
SKIP = %w[node_modules vendor tmp log].freeze
|
|
9
|
+
|
|
10
|
+
def self.full_name_from(url)
|
|
11
|
+
match = GITHUB_URL.match(url.to_s.strip)
|
|
12
|
+
match && "#{match[1]}/#{match[2]}".downcase
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(roots:, depth: 3, exclude: [])
|
|
16
|
+
@roots = roots
|
|
17
|
+
@depth = depth
|
|
18
|
+
@exclude = exclude.map { File.expand_path(_1) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def scan
|
|
22
|
+
found = {}
|
|
23
|
+
@roots.each { |root| walk(File.expand_path(root), 0, found) if File.directory?(root) }
|
|
24
|
+
found.sort.map { |full_name, path| { "full_name" => full_name, "path" => path } }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def walk(dir, level, found)
|
|
30
|
+
return if @exclude.any? { |excluded| dir == excluded || dir.start_with?("#{excluded}/") }
|
|
31
|
+
|
|
32
|
+
if File.exist?(File.join(dir, ".git"))
|
|
33
|
+
remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
return if level >= @depth
|
|
37
|
+
|
|
38
|
+
Dir.children(dir).sort.each do |child|
|
|
39
|
+
next if child.start_with?(".") || SKIP.include?(child)
|
|
40
|
+
|
|
41
|
+
path = File.join(dir, child)
|
|
42
|
+
walk(path, level + 1, found) if File.directory?(path) && !File.symlink?(path)
|
|
43
|
+
end
|
|
44
|
+
rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def remote_names(dir) = GitContext.remote_urls(dir).filter_map { |_name, url| self.class.full_name_from(url) }.uniq
|
|
49
|
+
end
|
|
50
|
+
end
|